| 1 | package hook |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | ) |
| 13 | |
| 14 | // --- Runner construction --- |
| 15 | |
| 16 | func TestNewRunnerNil(t *testing.T) { |
| 17 | var r *Runner |
| 18 | if r.Enabled() { |
| 19 | t.Error("nil Runner should not be enabled") |
| 20 | } |
| 21 | if r.Hooks() != nil { |
| 22 | t.Error("nil Runner.Hooks() should be nil") |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | func TestNewRunnerEmpty(t *testing.T) { |
| 27 | r := NewRunner(nil, "/tmp", nil, nil) |
| 28 | if r.Enabled() { |
| 29 | t.Error("empty hooks Runner should not be enabled") |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | func TestNewRunnerWithHooks(t *testing.T) { |
| 34 | hooks := []ResolvedHook{ |
| 35 | {HookConfig: HookConfig{Command: "echo hi"}, Event: PreToolUse, Scope: ScopeGlobal}, |
| 36 | } |
| 37 | r := NewRunner(hooks, "/tmp", nil, nil) |
| 38 | if !r.Enabled() { |
| 39 | t.Error("Runner with hooks should be enabled") |
| 40 | } |
| 41 | if len(r.Hooks()) != 1 { |
| 42 | t.Errorf("Hooks() count = %d, want 1", len(r.Hooks())) |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | func TestToolMutationHooksEnabled(t *testing.T) { |
| 47 | tests := []struct { |
| 48 | name string |
| 49 | event Event |
| 50 | want bool |
| 51 | }{ |
| 52 | {name: "session hook", event: SessionStart, want: false}, |
| 53 | {name: "pre tool", event: PreToolUse, want: true}, |
| 54 | {name: "post tool", event: PostToolUse, want: true}, |
| 55 | {name: "post tool failure", event: PostToolUseFailure, want: true}, |
| 56 | } |
| 57 | for _, tt := range tests { |
| 58 | t.Run(tt.name, func(t *testing.T) { |
| 59 | r := NewRunner([]ResolvedHook{{Event: tt.event}}, "/tmp", nil, nil) |
| 60 | if got := r.ToolMutationHooksEnabled(); got != tt.want { |
| 61 | t.Fatalf("ToolMutationHooksEnabled() = %v, want %v", got, tt.want) |
| 62 | } |
| 63 | }) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | // --- Runner.PreToolUse --- |
| 68 | |
| 69 | func TestRunnerPreToolUseNoHooks(t *testing.T) { |
| 70 | r := NewRunner(nil, "/tmp", nil, nil) |
| 71 | block, msg := r.PreToolUse(context.Background(), "bash", nil) |
| 72 | if block || msg != "" { |
| 73 | t.Errorf("no hooks should pass: block=%v msg=%q", block, msg) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | func TestRunnerPreToolUsePass(t *testing.T) { |
| 78 | hooks := []ResolvedHook{ |
| 79 | {HookConfig: HookConfig{Command: "allow"}, Event: PreToolUse}, |
| 80 | } |
| 81 | spawner := func(_ context.Context, in SpawnInput) SpawnResult { |
| 82 | return SpawnResult{ExitCode: 0} |
| 83 | } |
| 84 | r := NewRunner(hooks, "/tmp", spawner, nil) |
| 85 | block, msg := r.PreToolUse(context.Background(), "bash", nil) |
| 86 | if block { |
| 87 | t.Errorf("exit 0 should not block: msg=%q", msg) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | func TestRunnerPreToolUseBlock(t *testing.T) { |
| 92 | hooks := []ResolvedHook{ |
| 93 | {HookConfig: HookConfig{Command: "deny"}, Event: PreToolUse}, |
| 94 | } |
| 95 | spawner := func(_ context.Context, in SpawnInput) SpawnResult { |
| 96 | return SpawnResult{ExitCode: 2, Stderr: "blocked by policy"} |
| 97 | } |
| 98 | var notified string |
| 99 | notify := func(msg string) { notified = msg } |
| 100 | r := NewRunner(hooks, "/tmp", spawner, notify) |
| 101 | block, msg := r.PreToolUse(context.Background(), "bash", nil) |
| 102 | if !block { |
| 103 | t.Error("exit 2 on PreToolUse should block") |
| 104 | } |
| 105 | if msg == "" { |
| 106 | t.Error("block message should not be empty") |
| 107 | } |
| 108 | if notified == "" { |
| 109 | t.Error("notify should have been called") |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | // --- Runner.PostToolUse --- |
| 114 | |
| 115 | func TestRunnerPostToolUseNoHooks(t *testing.T) { |
| 116 | r := NewRunner(nil, "/tmp", nil, nil) |
| 117 | // Should not panic. |
| 118 | r.PostToolUse(context.Background(), "bash", nil, "ok") |
| 119 | } |
| 120 | |
| 121 | func TestRunnerPostToolUseWarn(t *testing.T) { |
| 122 | hooks := []ResolvedHook{ |
| 123 | {HookConfig: HookConfig{Command: "warn"}, Event: PostToolUse}, |
| 124 | } |
| 125 | spawner := func(_ context.Context, in SpawnInput) SpawnResult { |
| 126 | return SpawnResult{ExitCode: 1, Stdout: "warning message"} |
| 127 | } |
| 128 | var notified string |
| 129 | notify := func(msg string) { notified = msg } |
| 130 | r := NewRunner(hooks, "/tmp", spawner, notify) |
| 131 | r.PostToolUse(context.Background(), "bash", nil, "result") |
| 132 | if notified == "" { |
| 133 | t.Error("PostToolUse warn should notify") |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | func TestRunnerPostToolUseFailurePreservesNativeObserver(t *testing.T) { |
| 138 | hooks := []ResolvedHook{ |
| 139 | {HookConfig: HookConfig{Command: "claude-failure", PayloadFormat: "claude"}, Event: PostToolUseFailure}, |
| 140 | {HookConfig: HookConfig{Command: "native-post"}, Event: PostToolUse}, |
| 141 | } |
| 142 | var commands []string |
| 143 | r := NewRunner(hooks, "/tmp", func(_ context.Context, in SpawnInput) SpawnResult { |
| 144 | commands = append(commands, in.Command) |
| 145 | return SpawnResult{ExitCode: 0} |
| 146 | }, nil) |
| 147 | r.PostToolUseFailure(context.Background(), "bash", json.RawMessage(`{}`), "failed", errors.New("exit 1")) |
| 148 | if got := strings.Join(commands, ","); got != "claude-failure,native-post" { |
| 149 | t.Fatalf("failure observers = %q", got) |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | // --- Runner.PermissionRequest --- |
| 154 | |
| 155 | func TestRunnerPermissionRequestPayload(t *testing.T) { |
| 156 | hooks := []ResolvedHook{ |
| 157 | {HookConfig: HookConfig{Command: "notify", Match: "bash"}, Event: PermissionRequest}, |
| 158 | } |
| 159 | var got Payload |
| 160 | spawner := func(_ context.Context, in SpawnInput) SpawnResult { |
| 161 | if err := json.Unmarshal([]byte(in.Stdin), &got); err != nil { |
| 162 | t.Fatalf("payload json: %v", err) |
| 163 | } |
| 164 | return SpawnResult{ExitCode: 0} |
| 165 | } |
| 166 | args := json.RawMessage(`{"command":"go test ./..."}`) |
| 167 | r := NewRunner(hooks, "/tmp", spawner, nil) |
| 168 | r.PermissionRequest(context.Background(), "bash", "go test ./...", args) |
| 169 | |
| 170 | if got.Event != PermissionRequest { |
| 171 | t.Errorf("Event = %q, want PermissionRequest", got.Event) |
| 172 | } |
| 173 | if got.ToolName != "bash" { |
| 174 | t.Errorf("ToolName = %q, want bash", got.ToolName) |
| 175 | } |
| 176 | if got.Subject != "go test ./..." { |
| 177 | t.Errorf("Subject = %q, want command subject", got.Subject) |
| 178 | } |
| 179 | if string(got.ToolArgs) != string(args) { |
| 180 | t.Errorf("ToolArgs = %s, want %s", got.ToolArgs, args) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | func TestRunnerPermissionRequestWarnOnly(t *testing.T) { |
| 185 | hooks := []ResolvedHook{ |
| 186 | {HookConfig: HookConfig{Command: "warn"}, Event: PermissionRequest}, |
| 187 | } |
| 188 | spawner := func(_ context.Context, in SpawnInput) SpawnResult { |
| 189 | return SpawnResult{ExitCode: 2, Stderr: "notification failed"} |
| 190 | } |
| 191 | var notified string |
| 192 | r := NewRunner(hooks, "/tmp", spawner, func(msg string) { notified = msg }) |
| 193 | decision, _ := r.PermissionRequest(context.Background(), "bash", "go test", nil) |
| 194 | if decision != nil { |
| 195 | t.Errorf("native PermissionRequest hook must stay advisory-only, got decision=%v", *decision) |
| 196 | } |
| 197 | if notified == "" { |
| 198 | t.Error("PermissionRequest warn should notify") |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | func TestRunnerPermissionRequestClaudeDecisions(t *testing.T) { |
| 203 | claudeHooks := []ResolvedHook{{HookConfig: HookConfig{Command: "guard", PayloadFormat: "claude"}, Event: PermissionRequest}} |
| 204 | spawnerReturning := func(stdout string) Spawner { |
| 205 | return func(_ context.Context, in SpawnInput) SpawnResult { return SpawnResult{ExitCode: 0, Stdout: stdout} } |
| 206 | } |
| 207 | |
| 208 | denyJSON := `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny"}}}` |
| 209 | r := NewRunner(claudeHooks, "/tmp", spawnerReturning(denyJSON), nil) |
| 210 | decision, _ := r.PermissionRequest(context.Background(), "bash", "rm -rf /", nil) |
| 211 | if decision == nil || *decision != false { |
| 212 | t.Fatalf("Claude deny decision = %v, want false", decision) |
| 213 | } |
| 214 | |
| 215 | allowJSON := `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}` |
| 216 | r = NewRunner(claudeHooks, "/tmp", spawnerReturning(allowJSON), nil) |
| 217 | decision, _ = r.PermissionRequest(context.Background(), "bash", "go test", nil) |
| 218 | if decision == nil || *decision != true { |
| 219 | t.Fatalf("Claude allow decision = %v, want true", decision) |
| 220 | } |
| 221 | |
| 222 | r = NewRunner(claudeHooks, "/tmp", spawnerReturning(""), nil) |
| 223 | decision, _ = r.PermissionRequest(context.Background(), "bash", "go test", nil) |
| 224 | if decision != nil { |
| 225 | t.Fatalf("no opinion from the hook should return a nil decision, got %v", *decision) |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | // --- Runner.PromptSubmit --- |
| 230 | |
| 231 | func TestRunnerPromptSubmitBlock(t *testing.T) { |
| 232 | hooks := []ResolvedHook{ |
| 233 | {HookConfig: HookConfig{Command: "gate"}, Event: UserPromptSubmit}, |
| 234 | } |
| 235 | spawner := func(_ context.Context, in SpawnInput) SpawnResult { |
| 236 | return SpawnResult{ExitCode: 2, Stderr: "not allowed"} |
| 237 | } |
| 238 | r := NewRunner(hooks, "/tmp", spawner, nil) |
| 239 | block, _ := r.PromptSubmit(context.Background(), "bad input", 1) |
| 240 | if !block { |
| 241 | t.Error("exit 2 on UserPromptSubmit should block") |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | // --- Runner.Stop --- |
| 246 | |
| 247 | func TestRunnerStopNoHooks(t *testing.T) { |
| 248 | r := NewRunner(nil, "/tmp", nil, nil) |
| 249 | // Should not panic. |
| 250 | r.Stop(context.Background(), "last answer", 1) |
| 251 | } |
| 252 | |
| 253 | func TestRunnerStopWithHooks(t *testing.T) { |
| 254 | hooks := []ResolvedHook{ |
| 255 | {HookConfig: HookConfig{Command: "log"}, Event: Stop}, |
| 256 | } |
| 257 | spawner := func(_ context.Context, in SpawnInput) SpawnResult { |
| 258 | return SpawnResult{ExitCode: 0} |
| 259 | } |
| 260 | r := NewRunner(hooks, "/tmp", spawner, nil) |
| 261 | r.Stop(context.Background(), "done", 1) |
| 262 | } |
| 263 | |
| 264 | func TestRunnerStopResultPreservesNativeStopObserver(t *testing.T) { |
| 265 | hooks := []ResolvedHook{ |
| 266 | {HookConfig: HookConfig{Command: "claude-stop-failure", PayloadFormat: "claude"}, Event: StopFailure}, |
| 267 | {HookConfig: HookConfig{Command: "native-stop"}, Event: Stop}, |
| 268 | } |
| 269 | var commands []string |
| 270 | r := NewRunner(hooks, "/tmp", func(_ context.Context, in SpawnInput) SpawnResult { |
| 271 | commands = append(commands, in.Command) |
| 272 | return SpawnResult{ExitCode: 0} |
| 273 | }, nil) |
| 274 | r.StopResult(context.Background(), "partial", 1, errors.New("turn failed")) |
| 275 | if got := strings.Join(commands, ","); got != "claude-stop-failure,native-stop" { |
| 276 | t.Fatalf("stop failure observers = %q", got) |
| 277 | } |
| 278 | } |
| 279 | |
| 280 | func TestRunnerSessionStartReturnsAdditionalContexts(t *testing.T) { |
| 281 | hooks := []ResolvedHook{ |
| 282 | {HookConfig: HookConfig{Command: "plain"}, Event: SessionStart}, |
| 283 | {HookConfig: HookConfig{Command: "json"}, Event: SessionStart}, |
| 284 | } |
| 285 | spawner := func(_ context.Context, in SpawnInput) SpawnResult { |
| 286 | switch in.Command { |
| 287 | case "plain": |
| 288 | return SpawnResult{ExitCode: 0, Stdout: "Load notes."} |
| 289 | case "json": |
| 290 | return SpawnResult{ExitCode: 0, Stdout: `{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Use Superpowers."}}`} |
| 291 | default: |
| 292 | return SpawnResult{ExitCode: 1, Stderr: "unexpected"} |
| 293 | } |
| 294 | } |
| 295 | r := NewRunner(hooks, "/tmp", spawner, nil) |
| 296 | got := r.SessionStart(context.Background()) |
| 297 | if len(got) != 2 || got[0] != "Load notes." || got[1] != "Use Superpowers." { |
| 298 | t.Fatalf("SessionStart contexts = %#v", got) |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | func TestRunnerSessionStartReadsContextFile(t *testing.T) { |
| 303 | dir := t.TempDir() |
| 304 | contextPath := filepath.Join(dir, "CLAUDE.md") |
| 305 | if err := os.WriteFile(contextPath, []byte("Use the packaged workflow."), 0o644); err != nil { |
| 306 | t.Fatal(err) |
| 307 | } |
| 308 | calledSpawner := false |
| 309 | r := NewRunner([]ResolvedHook{{ |
| 310 | HookConfig: HookConfig{ContextFile: contextPath, Description: "Plugin CLAUDE.md"}, |
| 311 | Event: SessionStart, |
| 312 | Scope: ScopePlugin, |
| 313 | }}, dir, func(context.Context, SpawnInput) SpawnResult { |
| 314 | calledSpawner = true |
| 315 | return SpawnResult{ExitCode: 1} |
| 316 | }, nil) |
| 317 | |
| 318 | got := r.SessionStart(context.Background()) |
| 319 | if calledSpawner { |
| 320 | t.Fatal("context file hook should not invoke shell spawner") |
| 321 | } |
| 322 | if len(got) != 1 || got[0] != "Use the packaged workflow." { |
| 323 | t.Fatalf("SessionStart contexts = %#v", got) |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | func TestRunnerSessionStartWarnsOnInvalidJSON(t *testing.T) { |
| 328 | hooks := []ResolvedHook{{HookConfig: HookConfig{Command: "bad-json"}, Event: SessionStart}} |
| 329 | spawner := func(_ context.Context, in SpawnInput) SpawnResult { |
| 330 | return SpawnResult{ExitCode: 0, Stdout: `{"hookSpecificOutput":`} |
| 331 | } |
| 332 | var notified string |
| 333 | r := NewRunner(hooks, "/tmp", spawner, func(msg string) { notified = msg }) |
| 334 | if got := r.SessionStart(context.Background()); len(got) != 0 { |
| 335 | t.Fatalf("SessionStart contexts = %#v, want none", got) |
| 336 | } |
| 337 | if !contains(notified, "invalid JSON") { |
| 338 | t.Fatalf("notify = %q, want invalid JSON warning", notified) |
| 339 | } |
| 340 | } |
| 341 | |
| 342 | func TestRunnerClaudeLifecyclePayloadsShareSessionID(t *testing.T) { |
| 343 | events := []Event{SessionStart, PreCompact, Notification, SessionEnd} |
| 344 | hooks := make([]ResolvedHook, 0, len(events)) |
| 345 | for _, event := range events { |
| 346 | hooks = append(hooks, ResolvedHook{ |
| 347 | HookConfig: HookConfig{Command: string(event), PayloadFormat: "claude"}, |
| 348 | Event: event, |
| 349 | }) |
| 350 | } |
| 351 | seen := map[Event]map[string]any{} |
| 352 | spawner := func(_ context.Context, in SpawnInput) SpawnResult { |
| 353 | var payload map[string]any |
| 354 | if err := json.Unmarshal([]byte(in.Stdin), &payload); err != nil { |
| 355 | t.Fatalf("payload JSON: %v", err) |
| 356 | } |
| 357 | seen[Event(payload["hook_event_name"].(string))] = payload |
| 358 | return SpawnResult{ExitCode: 0} |
| 359 | } |
| 360 | r := NewRunner(hooks, "/workspace", spawner, nil) |
| 361 | r.SetSessionID("session-42") |
| 362 | r.SessionStart(context.Background(), "resume") |
| 363 | r.PreCompact(context.Background(), "manual") |
| 364 | r.Notification(context.Background(), "approval needed", "permission_prompt") |
| 365 | r.SessionEnd(context.Background(), "clear") |
| 366 | |
| 367 | for _, event := range events { |
| 368 | if seen[event]["session_id"] != "session-42" { |
| 369 | t.Fatalf("%s session_id = %#v", event, seen[event]["session_id"]) |
| 370 | } |
| 371 | } |
| 372 | if seen[SessionStart]["source"] != "resume" || seen[PreCompact]["trigger"] != "manual" { |
| 373 | t.Fatalf("lifecycle details = %#v / %#v", seen[SessionStart], seen[PreCompact]) |
| 374 | } |
| 375 | if seen[Notification]["notification_type"] != "permission_prompt" || seen[Notification]["message"] != "approval needed" { |
| 376 | t.Fatalf("notification payload = %#v", seen[Notification]) |
| 377 | } |
| 378 | if seen[SessionEnd]["reason"] != "clear" { |
| 379 | t.Fatalf("session end payload = %#v", seen[SessionEnd]) |
| 380 | } |
| 381 | } |
| 382 | |
| 383 | // --- Runner.PostLLMCall --- |
| 384 | |
| 385 | func TestRunnerHasPostLLMCall(t *testing.T) { |
| 386 | with := NewRunner([]ResolvedHook{{HookConfig: HookConfig{Command: "x"}, Event: PostLLMCall}}, "/tmp", nil, nil) |
| 387 | if !with.HasPostLLMCall() { |
| 388 | t.Error("a configured PostLLMCall hook should report HasPostLLMCall") |
| 389 | } |
| 390 | without := NewRunner([]ResolvedHook{{HookConfig: HookConfig{Command: "x"}, Event: Stop}}, "/tmp", nil, nil) |
| 391 | if without.HasPostLLMCall() { |
| 392 | t.Error("only a Stop hook should not report HasPostLLMCall") |
| 393 | } |
| 394 | if (*Runner)(nil).HasPostLLMCall() { |
| 395 | t.Error("nil runner should report no PostLLMCall hook") |
| 396 | } |
| 397 | } |
| 398 | |
| 399 | func TestRunnerPostLLMCallReplacesReasoning(t *testing.T) { |
| 400 | hooks := []ResolvedHook{{HookConfig: HookConfig{Command: "translate"}, Event: PostLLMCall}} |
| 401 | spawner := func(_ context.Context, in SpawnInput) SpawnResult { |
| 402 | return SpawnResult{ExitCode: 0, Stdout: " 译文 "} |
| 403 | } |
| 404 | r := NewRunner(hooks, "/tmp", spawner, nil) |
| 405 | if got := r.PostLLMCall(context.Background(), "raw reasoning", 2); got != "译文" { |
| 406 | t.Fatalf("PostLLMCall = %q, want trimmed hook stdout", got) |
| 407 | } |
| 408 | } |
| 409 | |
| 410 | func TestRunnerPostLLMCallKeepsOriginal(t *testing.T) { |
| 411 | cases := []struct { |
| 412 | name string |
| 413 | hooks []ResolvedHook |
| 414 | spawn SpawnResult |
| 415 | }{ |
| 416 | {"no PostLLMCall hook", []ResolvedHook{{HookConfig: HookConfig{Command: "x"}, Event: Stop}}, SpawnResult{ExitCode: 0, Stdout: "ignored"}}, |
| 417 | {"empty stdout", []ResolvedHook{{HookConfig: HookConfig{Command: "x"}, Event: PostLLMCall}}, SpawnResult{ExitCode: 0, Stdout: " "}}, |
| 418 | {"non-zero exit", []ResolvedHook{{HookConfig: HookConfig{Command: "x"}, Event: PostLLMCall}}, SpawnResult{ExitCode: 1, Stdout: "should be ignored"}}, |
| 419 | } |
| 420 | for _, tc := range cases { |
| 421 | t.Run(tc.name, func(t *testing.T) { |
| 422 | r := NewRunner(tc.hooks, "/tmp", func(context.Context, SpawnInput) SpawnResult { return tc.spawn }, nil) |
| 423 | if got := r.PostLLMCall(context.Background(), "raw", 1); got != "raw" { |
| 424 | t.Fatalf("PostLLMCall = %q, want original reasoning preserved", got) |
| 425 | } |
| 426 | }) |
| 427 | } |
| 428 | } |
| 429 | |
| 430 | // --- FormatOutcome --- |
| 431 | |
| 432 | func TestFormatOutcomePass(t *testing.T) { |
| 433 | o := Outcome{ |
| 434 | Hook: ResolvedHook{HookConfig: HookConfig{Command: "echo hi"}, Event: PreToolUse, Scope: ScopeProject}, |
| 435 | Decision: DecisionPass, |
| 436 | } |
| 437 | msg := FormatOutcome(o) |
| 438 | if msg == "" { |
| 439 | t.Error("FormatOutcome should not be empty") |
| 440 | } |
| 441 | } |
| 442 | |
| 443 | func TestFormatOutcomeWithDetail(t *testing.T) { |
| 444 | o := Outcome{ |
| 445 | Hook: ResolvedHook{HookConfig: HookConfig{Command: "check"}, Event: PreToolUse, Scope: ScopeGlobal}, |
| 446 | Decision: DecisionBlock, |
| 447 | Stderr: "forbidden", |
| 448 | Truncated: true, |
| 449 | } |
| 450 | msg := FormatOutcome(o) |
| 451 | if !contains(msg, "forbidden") { |
| 452 | t.Errorf("should include stderr: %s", msg) |
| 453 | } |
| 454 | if !contains(msg, "truncated") { |
| 455 | t.Errorf("should mention truncation: %s", msg) |
| 456 | } |
| 457 | } |
| 458 | |
| 459 | // --- clipRunes --- |
| 460 | |
| 461 | func TestClipRunes(t *testing.T) { |
| 462 | if got := clipRunes("short", 10); got != "short" { |
| 463 | t.Errorf("clipRunes short = %q", got) |
| 464 | } |
| 465 | if got := clipRunes("hello world", 5); got != "hello…" { |
| 466 | t.Errorf("clipRunes = %q", got) |
| 467 | } |
| 468 | if got := clipRunes("", 5); got != "" { |
| 469 | t.Errorf("clipRunes empty = %q", got) |
| 470 | } |
| 471 | if got := clipRunes("abc", 0); got != "" { |
| 472 | t.Errorf("clipRunes max=0 = %q", got) |
| 473 | } |
| 474 | } |
| 475 | |
| 476 | // --- payload JSON --- |
| 477 | |
| 478 | func TestPayloadJSON(t *testing.T) { |
| 479 | args := json.RawMessage(`{"command":"echo hi"}`) |
| 480 | p := Payload{ |
| 481 | Event: PreToolUse, |
| 482 | Cwd: "/tmp", |
| 483 | ToolName: "bash", |
| 484 | ToolArgs: args, |
| 485 | Turn: 1, |
| 486 | } |
| 487 | b, err := json.Marshal(p) |
| 488 | if err != nil { |
| 489 | t.Fatalf("marshal: %v", err) |
| 490 | } |
| 491 | var decoded Payload |
| 492 | if err := json.Unmarshal(b, &decoded); err != nil { |
| 493 | t.Fatalf("unmarshal: %v", err) |
| 494 | } |
| 495 | if decoded.Event != PreToolUse { |
| 496 | t.Errorf("Event = %q", decoded.Event) |
| 497 | } |
| 498 | if decoded.ToolName != "bash" { |
| 499 | t.Errorf("ToolName = %q", decoded.ToolName) |
| 500 | } |
| 501 | if decoded.Turn != 1 { |
| 502 | t.Errorf("Turn = %d", decoded.Turn) |
| 503 | } |
| 504 | } |
| 505 | |
| 506 | // --- capping behavior --- |
| 507 | |
| 508 | func TestCappedBuffer(t *testing.T) { |
| 509 | var cb cappedBuffer |
| 510 | // Write within cap. |
| 511 | n, err := cb.Write([]byte("hello")) |
| 512 | if err != nil || n != 5 { |
| 513 | t.Errorf("small write: n=%d err=%v", n, err) |
| 514 | } |
| 515 | if cb.truncated { |
| 516 | t.Error("should not be truncated yet") |
| 517 | } |
| 518 | if cb.String() != "hello" { |
| 519 | t.Errorf("String() = %q", cb.String()) |
| 520 | } |
| 521 | |
| 522 | // Write beyond cap. |
| 523 | big := make([]byte, outputCapBytes+1000) |
| 524 | for i := range big { |
| 525 | big[i] = 'x' |
| 526 | } |
| 527 | n, err = cb.Write(big) |
| 528 | if err != nil || n != len(big) { |
| 529 | t.Errorf("big write: n=%d err=%v", n, err) |
| 530 | } |
| 531 | if !cb.truncated { |
| 532 | t.Error("should be truncated after exceeding cap") |
| 533 | } |
| 534 | } |
| 535 | |
| 536 | // --- IsBlocking --- |
| 537 | |
| 538 | func TestIsBlocking(t *testing.T) { |
| 539 | if !IsBlocking(PreToolUse) { |
| 540 | t.Error("PreToolUse should be blocking") |
| 541 | } |
| 542 | if !IsBlocking(UserPromptSubmit) { |
| 543 | t.Error("UserPromptSubmit should be blocking") |
| 544 | } |
| 545 | if IsBlocking(PostToolUse) { |
| 546 | t.Error("PostToolUse should not be blocking") |
| 547 | } |
| 548 | if IsBlocking(Stop) { |
| 549 | t.Error("Stop should not be blocking") |
| 550 | } |
| 551 | } |
| 552 | |
| 553 | // --- defaultTimeout --- |
| 554 | |
| 555 | func TestDefaultTimeout(t *testing.T) { |
| 556 | if defaultTimeout(PreToolUse) != 5*time.Second { |
| 557 | t.Errorf("PreToolUse timeout = %v", defaultTimeout(PreToolUse)) |
| 558 | } |
| 559 | if defaultTimeout(PermissionRequest) != 5*time.Second { |
| 560 | t.Errorf("PermissionRequest timeout = %v", defaultTimeout(PermissionRequest)) |
| 561 | } |
| 562 | if defaultTimeout(PostToolUse) != 30*time.Second { |
| 563 | t.Errorf("PostToolUse timeout = %v", defaultTimeout(PostToolUse)) |
| 564 | } |
| 565 | } |
| 566 | |
| 567 | // helper |
| 568 | func contains(s, sub string) bool { |
| 569 | for i := 0; i <= len(s)-len(sub); i++ { |
| 570 | if s[i:i+len(sub)] == sub { |
| 571 | return true |
| 572 | } |
| 573 | } |
| 574 | return false |
| 575 | } |
| 576 |