package hooks import ( "context" "encoding/json" "os" "path/filepath" "runtime" "strings" "testing" "time" ) // call is one command a fake shell was asked to run. type call struct { line string env []string stdin []byte dir string } // fakeShell replaces the runner's exec with a recorder, and optionally fails. func fakeShell(fail map[string]error) (*[]call, func(context.Context, string, []string, []byte, string) error) { var calls []call return &calls, func(_ context.Context, line string, env []string, stdin []byte, dir string) error { calls = append(calls, call{line: line, env: env, stdin: stdin, dir: dir}) return fail[line] } } func TestNewRunnerIsNilWhenNothingIsConfigured(t *testing.T) { for _, spec := range []map[string][]string{nil, {}, {"turn_start": nil}, {"turn_start": {""}}} { r, err := NewRunner(spec, "/tmp") if err != nil { t.Fatalf("NewRunner(%v): %v", spec, err) } if r != nil { t.Errorf("NewRunner(%v) built a runner for nothing to run", spec) } } } // A hook on an event that does not exist would never fire, and a hook that // never fires is the one kind of bug with nothing to notice. func TestNewRunnerRejectsAnUnknownEvent(t *testing.T) { _, err := NewRunner(map[string][]string{"turn_started": {"true"}}, "") if err == nil { t.Fatal("a misspelt event name was accepted") } if !strings.Contains(err.Error(), "turn_started") { t.Errorf("the error does not name the bad event: %v", err) } if !strings.Contains(err.Error(), string(TurnStart)) { t.Errorf("the error does not say what the names are: %v", err) } } func TestHandleRunsOnlyTheCommandsForThatEvent(t *testing.T) { calls, shell := fakeShell(nil) r := &Runner{ Commands: map[Event][]string{ RunEnd: {"first", "second"}, RunStart: {"not this one"}, }, run: shell, } if err := r.Handle(context.Background(), Payload{Event: RunEnd}); err != nil { t.Fatalf("Handle: %v", err) } if len(*calls) != 2 { t.Fatalf("ran %d commands, want the two configured for run_end", len(*calls)) } if (*calls)[0].line != "first" || (*calls)[1].line != "second" { t.Errorf("ran %q then %q, want them in the configured order", (*calls)[0].line, (*calls)[1].line) } } func TestHandleIgnoresAnEventWithNoCommands(t *testing.T) { calls, shell := fakeShell(nil) r := &Runner{Commands: map[Event][]string{RunEnd: {"x"}}, run: shell} if err := r.Handle(context.Background(), Payload{Event: ToolStart}); err != nil { t.Fatalf("Handle: %v", err) } if len(*calls) != 0 { t.Errorf("ran %d commands for an event with none configured", len(*calls)) } } func TestHandlePassesTheEventAsEnvironmentAndJSON(t *testing.T) { calls, shell := fakeShell(nil) r := &Runner{Commands: map[Event][]string{Blocked: {"notify"}}, Dir: "/work", run: shell} p := Payload{ Event: Blocked, Time: time.Date(2026, 9, 19, 12, 0, 0, 0, time.UTC), Session: "20260919T120000Z", Turn: 3, Tool: "bash", Reason: ReasonApproval, Message: "bash: rm -rf build", } if err := r.Handle(context.Background(), p); err != nil { t.Fatalf("Handle: %v", err) } got := (*calls)[0] if got.dir != "/work" { t.Errorf("command ran in %q, want the configured directory", got.dir) } want := map[string]string{ "CODEY_HOOK_EVENT": "blocked", "CODEY_SESSION": "20260919T120000Z", "CODEY_TURN": "3", "CODEY_TOOL": "bash", "CODEY_REASON": ReasonApproval, "CODEY_MESSAGE": "bash: rm -rf build", } for k, v := range want { if !hasEnv(got.env, k+"="+v) { t.Errorf("environment is missing %s=%s", k, v) } } var decoded jsonPayload if err := json.Unmarshal(got.stdin, &decoded); err != nil { t.Fatalf("stdin was not the event as JSON: %v", err) } if decoded.Event != "blocked" || decoded.Tool != "bash" || decoded.Message != "bash: rm -rf build" { t.Errorf("stdin carried %+v, want the event that was fired", decoded) } } // An empty field is still exported, so a one-line hook can read $CODEY_TOOL // without testing whether it is set. func TestEnvironmentIsCompleteEvenWhenFieldsAreEmpty(t *testing.T) { calls, shell := fakeShell(nil) r := &Runner{Commands: map[Event][]string{RunEnd: {"x"}}, run: shell} if err := r.Handle(context.Background(), Payload{Event: RunEnd}); err != nil { t.Fatalf("Handle: %v", err) } for _, key := range []string{"CODEY_TOOL", "CODEY_REASON", "CODEY_MESSAGE", "CODEY_ERROR", "CODEY_AGENT"} { if !hasEnv((*calls)[0].env, key+"=") { t.Errorf("%s was not exported at all", key) } } } func TestExtraEnvIsAdded(t *testing.T) { calls, shell := fakeShell(nil) r := &Runner{Commands: map[Event][]string{RunEnd: {"x"}}, Env: []string{"CODEY_ROOT=/repo"}, run: shell} if err := r.Handle(context.Background(), Payload{Event: RunEnd}); err != nil { t.Fatalf("Handle: %v", err) } if !hasEnv((*calls)[0].env, "CODEY_ROOT=/repo") { t.Error("the runner's own environment was not passed to the command") } } func TestOneFailingCommandDoesNotStopTheRest(t *testing.T) { calls, shell := fakeShell(map[string]error{"bad": os.ErrNotExist}) r := &Runner{Commands: map[Event][]string{RunEnd: {"bad", "good"}}, run: shell} err := r.Handle(context.Background(), Payload{Event: RunEnd}) if err == nil { t.Fatal("a failing command was reported as success") } if !strings.Contains(err.Error(), "bad") { t.Errorf("the error does not say which command failed: %v", err) } if len(*calls) != 2 { t.Errorf("ran %d commands, want the second one to have run anyway", len(*calls)) } } // The event that says a run ended arrives with the run's context already // cancelled when the user interrupted it. A hook that cannot fire on an // interrupt is a hook that cannot clean up after one. func TestHooksStillRunWhenTheTurnWasCancelled(t *testing.T) { calls, shell := fakeShell(nil) r := &Runner{Commands: map[Event][]string{RunEnd: {"cleanup"}}, run: shell} ctx, cancel := context.WithCancel(context.Background()) cancel() if err := r.Handle(ctx, Payload{Event: RunEnd}); err != nil { t.Fatalf("Handle: %v", err) } if len(*calls) != 1 { t.Error("the hook was skipped because the turn had been cancelled") } } func TestNilRunnerIsANoOp(t *testing.T) { var r *Runner if err := r.Handle(context.Background(), Payload{Event: RunEnd}); err != nil { t.Errorf("a nil runner returned %v", err) } } // The real shell, once: everything above stubs it out, and a hook system that // runs nothing on the machine it is on would pass all of those tests. func TestRealCommandRunsWithTheEventInItsEnvironment(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("the command line under test is POSIX shell") } dir := t.TempDir() out := filepath.Join(dir, "fired") r, err := NewRunner(map[string][]string{ "blocked": {"printf '%s %s' \"$CODEY_HOOK_EVENT\" \"$CODEY_TOOL\" > " + out}, }, dir) if err != nil { t.Fatalf("NewRunner: %v", err) } if err := r.Handle(context.Background(), Payload{Event: Blocked, Tool: "bash"}); err != nil { t.Fatalf("Handle: %v", err) } got, err := os.ReadFile(out) if err != nil { t.Fatalf("the hook did not run: %v", err) } if string(got) != "blocked bash" { t.Errorf("the hook saw %q, want the event and tool it was fired with", got) } } func TestARealCommandThatHangsIsGivenUpOn(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("the command line under test is POSIX shell") } r := &Runner{ Commands: map[Event][]string{RunEnd: {"sleep 30"}}, Timeout: 50 * time.Millisecond, } start := time.Now() err := r.Handle(context.Background(), Payload{Event: RunEnd}) elapsed := time.Since(start) if err == nil { t.Error("a command killed by the timeout was reported as success") } // The bound is what matters, not the exact moment: a hook runs on the // agent's goroutine, so the session waits exactly this long and no longer. if elapsed > 5*time.Second { t.Errorf("the hook took %v, want it abandoned at the timeout", elapsed) } } func TestRealCommandFailureCarriesItsOutput(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("the command line under test is POSIX shell") } r := &Runner{Commands: map[Event][]string{RunEnd: {"echo what went wrong >&2; exit 3"}}} err := r.Handle(context.Background(), Payload{Event: RunEnd}) if err == nil { t.Fatal("a command exiting 3 was reported as success") } // Without the output there is nothing to debug from: the config says only // that a command was run, not why it did not work. if !strings.Contains(err.Error(), "what went wrong") { t.Errorf("the error dropped the command's output: %v", err) } } func hasEnv(env []string, want string) bool { for _, e := range env { if e == want || (strings.HasSuffix(want, "=") && strings.HasPrefix(e, want)) { return true } } return false }