hooks_test.go

  1package hooks
  2
  3import (
  4	"context"
  5	"errors"
  6	"os"
  7	"path/filepath"
  8	"strings"
  9	"testing"
 10	"time"
 11
 12	"github.com/charmbracelet/crush/internal/config"
 13	"github.com/stretchr/testify/require"
 14)
 15
 16func TestHookExecutor_Execute(t *testing.T) {
 17	t.Parallel()
 18
 19	tempDir := t.TempDir()
 20
 21	tests := []struct {
 22		name    string
 23		config  config.HookConfig
 24		hookCtx HookContext
 25		wantErr bool
 26	}{
 27		{
 28			name: "simple command hook",
 29			config: config.HookConfig{
 30				config.PreToolUse: []config.HookMatcher{
 31					{
 32						Matcher: "bash",
 33						Hooks: []config.Hook{
 34							{
 35								Type:    "command",
 36								Command: "echo 'hook executed'",
 37							},
 38						},
 39					},
 40				},
 41			},
 42			hookCtx: HookContext{
 43				EventType: config.PreToolUse,
 44				ToolName:  "bash",
 45			},
 46		},
 47		{
 48			name: "hook with jq processing",
 49			config: config.HookConfig{
 50				config.PreToolUse: []config.HookMatcher{
 51					{
 52						Matcher: "bash",
 53						Hooks: []config.Hook{
 54							{
 55								Type:    "command",
 56								Command: `jq -r '.tool_name'`,
 57							},
 58						},
 59					},
 60				},
 61			},
 62			hookCtx: HookContext{
 63				EventType: config.PreToolUse,
 64				ToolName:  "bash",
 65			},
 66		},
 67		{
 68			name: "hook that writes to file",
 69			config: config.HookConfig{
 70				config.PostToolUse: []config.HookMatcher{
 71					{
 72						Matcher: "*",
 73						Hooks: []config.Hook{
 74							{
 75								Type:    "command",
 76								Command: `jq -r '"\(.tool_name): \(.tool_result)"' >> ` + filepath.Join(tempDir, "hook-log.txt"),
 77							},
 78						},
 79					},
 80				},
 81			},
 82			hookCtx: HookContext{
 83				EventType:  config.PostToolUse,
 84				ToolName:   "edit",
 85				ToolResult: "file edited successfully",
 86			},
 87		},
 88		{
 89			name: "hook with timeout",
 90			config: config.HookConfig{
 91				config.Stop: []config.HookMatcher{
 92					{
 93						Hooks: []config.Hook{
 94							{
 95								Type:    "command",
 96								Command: "sleep 0.1 && echo 'done'",
 97								Timeout: ptrInt(1),
 98							},
 99						},
100					},
101				},
102			},
103			hookCtx: HookContext{
104				EventType: config.Stop,
105			},
106		},
107		{
108			name: "failed hook command",
109			config: config.HookConfig{
110				config.PreToolUse: []config.HookMatcher{
111					{
112						Matcher: "bash",
113						Hooks: []config.Hook{
114							{
115								Type:    "command",
116								Command: "exit 1",
117							},
118						},
119					},
120				},
121			},
122			hookCtx: HookContext{
123				EventType: config.PreToolUse,
124				ToolName:  "bash",
125			},
126			wantErr: true,
127		},
128		{
129			name: "hook with single quote in JSON",
130			config: config.HookConfig{
131				config.PostToolUse: []config.HookMatcher{
132					{
133						Matcher: "edit",
134						Hooks: []config.Hook{
135							{
136								Type:    "command",
137								Command: `jq -r '.tool_result'`,
138							},
139						},
140					},
141				},
142			},
143			hookCtx: HookContext{
144				EventType:  config.PostToolUse,
145				ToolName:   "edit",
146				ToolResult: "it's a test with 'quotes'",
147			},
148		},
149	}
150
151	for _, tt := range tests {
152		t.Run(tt.name, func(t *testing.T) {
153			t.Parallel()
154
155			executor := NewExecutor(tt.config, tempDir)
156			require.NotNil(t, executor)
157
158			ctx := context.Background()
159			err := executor.Execute(ctx, tt.hookCtx)
160
161			if tt.wantErr {
162				require.Error(t, err)
163			} else {
164				require.NoError(t, err)
165			}
166		})
167	}
168}
169
170func TestHookExecutor_MatcherApplies(t *testing.T) {
171	t.Parallel()
172
173	tempDir := t.TempDir()
174	executor := NewExecutor(config.HookConfig{}, tempDir)
175
176	tests := []struct {
177		name    string
178		matcher config.HookMatcher
179		ctx     HookContext
180		want    bool
181	}{
182		{
183			name: "empty matcher matches all",
184			matcher: config.HookMatcher{
185				Matcher: "",
186			},
187			ctx: HookContext{
188				EventType: config.PreToolUse,
189				ToolName:  "bash",
190			},
191			want: true,
192		},
193		{
194			name: "wildcard matcher matches all",
195			matcher: config.HookMatcher{
196				Matcher: "*",
197			},
198			ctx: HookContext{
199				EventType: config.PreToolUse,
200				ToolName:  "edit",
201			},
202			want: true,
203		},
204		{
205			name: "specific tool matcher matches",
206			matcher: config.HookMatcher{
207				Matcher: "bash",
208			},
209			ctx: HookContext{
210				EventType: config.PreToolUse,
211				ToolName:  "bash",
212			},
213			want: true,
214		},
215		{
216			name: "specific tool matcher doesn't match different tool",
217			matcher: config.HookMatcher{
218				Matcher: "bash",
219			},
220			ctx: HookContext{
221				EventType: config.PreToolUse,
222				ToolName:  "edit",
223			},
224			want: false,
225		},
226		{
227			name: "pipe-separated matcher matches first tool",
228			matcher: config.HookMatcher{
229				Matcher: "edit|write|multiedit",
230			},
231			ctx: HookContext{
232				EventType: config.PreToolUse,
233				ToolName:  "edit",
234			},
235			want: true,
236		},
237		{
238			name: "pipe-separated matcher matches middle tool",
239			matcher: config.HookMatcher{
240				Matcher: "edit|write|multiedit",
241			},
242			ctx: HookContext{
243				EventType: config.PreToolUse,
244				ToolName:  "write",
245			},
246			want: true,
247		},
248		{
249			name: "pipe-separated matcher matches last tool",
250			matcher: config.HookMatcher{
251				Matcher: "edit|write|multiedit",
252			},
253			ctx: HookContext{
254				EventType: config.PreToolUse,
255				ToolName:  "multiedit",
256			},
257			want: true,
258		},
259		{
260			name: "pipe-separated matcher doesn't match different tool",
261			matcher: config.HookMatcher{
262				Matcher: "edit|write|multiedit",
263			},
264			ctx: HookContext{
265				EventType: config.PreToolUse,
266				ToolName:  "bash",
267			},
268			want: false,
269		},
270		{
271			name: "pipe-separated matcher with spaces",
272			matcher: config.HookMatcher{
273				Matcher: "edit | write | multiedit",
274			},
275			ctx: HookContext{
276				EventType: config.PreToolUse,
277				ToolName:  "write",
278			},
279			want: true,
280		},
281		{
282			name: "non-tool event matches empty matcher",
283			matcher: config.HookMatcher{
284				Matcher: "",
285			},
286			ctx: HookContext{
287				EventType: config.Stop,
288			},
289			want: true,
290		},
291	}
292
293	for _, tt := range tests {
294		t.Run(tt.name, func(t *testing.T) {
295			t.Parallel()
296
297			got := executor.matcherApplies(tt.matcher, tt.ctx)
298			require.Equal(t, tt.want, got)
299		})
300	}
301}
302
303func TestHookExecutor_Timeout(t *testing.T) {
304	t.Parallel()
305
306	tempDir := t.TempDir()
307	shortTimeout := 1
308
309	hookConfig := config.HookConfig{
310		config.Stop: []config.HookMatcher{
311			{
312				Hooks: []config.Hook{
313					{
314						Type:    "command",
315						Command: "sleep 10",
316						Timeout: &shortTimeout,
317					},
318				},
319			},
320		},
321	}
322
323	executor := NewExecutor(hookConfig, tempDir)
324	ctx := context.Background()
325
326	start := time.Now()
327	err := executor.Execute(ctx, HookContext{
328		EventType: config.Stop,
329	})
330	duration := time.Since(start)
331
332	require.Error(t, err)
333	require.Less(t, duration, 2*time.Second)
334}
335
336func TestHookExecutor_MultipleHooks(t *testing.T) {
337	t.Parallel()
338
339	tempDir := t.TempDir()
340	logFile := filepath.Join(tempDir, "multi-hook-log.txt")
341
342	hookConfig := config.HookConfig{
343		config.PreToolUse: []config.HookMatcher{
344			{
345				Matcher: "bash",
346				Hooks: []config.Hook{
347					{
348						Type:    "command",
349						Command: "echo 'hook1' >> " + logFile,
350					},
351					{
352						Type:    "command",
353						Command: "echo 'hook2' >> " + logFile,
354					},
355					{
356						Type:    "command",
357						Command: "echo 'hook3' >> " + logFile,
358					},
359				},
360			},
361		},
362	}
363
364	executor := NewExecutor(hookConfig, tempDir)
365	ctx := context.Background()
366
367	err := executor.Execute(ctx, HookContext{
368		EventType: config.PreToolUse,
369		ToolName:  "bash",
370	})
371
372	require.NoError(t, err)
373
374	content, err := os.ReadFile(logFile)
375	require.NoError(t, err)
376
377	lines := strings.Split(strings.TrimSpace(string(content)), "\n")
378	require.Len(t, lines, 3)
379	require.Equal(t, "hook1", strings.TrimSpace(lines[0]))
380	require.Equal(t, "hook2", strings.TrimSpace(lines[1]))
381	require.Equal(t, "hook3", strings.TrimSpace(lines[2]))
382}
383
384func TestHookExecutor_PipeSeparatedMatcher(t *testing.T) {
385	t.Parallel()
386
387	tempDir := t.TempDir()
388	logFile := filepath.Join(tempDir, "pipe-matcher-log.txt")
389
390	hookConfig := config.HookConfig{
391		config.PostToolUse: []config.HookMatcher{
392			{
393				Matcher: "edit|write|multiedit",
394				Hooks: []config.Hook{
395					{
396						Type:    "command",
397						Command: `jq -r '.tool_name' >> ` + logFile,
398					},
399				},
400			},
401		},
402	}
403
404	executor := NewExecutor(hookConfig, tempDir)
405	ctx := context.Background()
406
407	// Test that edit triggers the hook
408	err := executor.Execute(ctx, HookContext{
409		EventType: config.PostToolUse,
410		ToolName:  "edit",
411	})
412	require.NoError(t, err)
413
414	// Test that write triggers the hook
415	err = executor.Execute(ctx, HookContext{
416		EventType: config.PostToolUse,
417		ToolName:  "write",
418	})
419	require.NoError(t, err)
420
421	// Test that multiedit triggers the hook
422	err = executor.Execute(ctx, HookContext{
423		EventType: config.PostToolUse,
424		ToolName:  "multiedit",
425	})
426	require.NoError(t, err)
427
428	// Test that bash does NOT trigger the hook
429	err = executor.Execute(ctx, HookContext{
430		EventType: config.PostToolUse,
431		ToolName:  "bash",
432	})
433	require.NoError(t, err)
434
435	// Verify only the matching tools were logged
436	content, err := os.ReadFile(logFile)
437	require.NoError(t, err)
438
439	lines := strings.Split(strings.TrimSpace(string(content)), "\n")
440	require.Len(t, lines, 3)
441	require.Equal(t, "edit", strings.TrimSpace(lines[0]))
442	require.Equal(t, "write", strings.TrimSpace(lines[1]))
443	require.Equal(t, "multiedit", strings.TrimSpace(lines[2]))
444}
445
446func TestHookExecutor_ContextCancellation(t *testing.T) {
447	t.Parallel()
448
449	tempDir := t.TempDir()
450	logFile := filepath.Join(tempDir, "cancel-log.txt")
451
452	hookConfig := config.HookConfig{
453		config.PreToolUse: []config.HookMatcher{
454			{
455				Matcher: "bash",
456				Hooks: []config.Hook{
457					{
458						Type:    "command",
459						Command: "echo 'hook1' >> " + logFile,
460					},
461					{
462						Type:    "command",
463						Command: "sleep 10 && echo 'hook2' >> " + logFile,
464					},
465				},
466			},
467		},
468	}
469
470	executor := NewExecutor(hookConfig, tempDir)
471	ctx, cancel := context.WithCancel(context.Background())
472
473	go func() {
474		time.Sleep(100 * time.Millisecond)
475		cancel()
476	}()
477
478	err := executor.Execute(ctx, HookContext{
479		EventType: config.PreToolUse,
480		ToolName:  "bash",
481	})
482
483	require.Error(t, err)
484	// On context cancellation, the shell may return either context.Canceled or an exit status
485	// depending on timing. Check for both.
486	if !errors.Is(err, context.Canceled) {
487		// If not context.Canceled, we should still have an error from the cancelled context
488		require.NotNil(t, ctx.Err())
489	}
490}
491
492func ptrInt(i int) *int {
493	return &i
494}