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
}