Vectrum
Sign In
280 lines · 8.6 KB · Go
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
}