package hooks
import (
"context"
"errors"
"sync"
"testing"
"time"
)
// recorder is a handler that keeps what it was given.
type recorder struct {
mu sync.Mutex
got []Payload
err error
fail bool
}
func (r *recorder) Handle(_ context.Context, p Payload) error {
r.mu.Lock()
defer r.mu.Unlock()
r.got = append(r.got, p)
if r.fail {
return r.err
}
return nil
}
func (r *recorder) events() []Event {
r.mu.Lock()
defer r.mu.Unlock()
var out []Event
for _, p := range r.got {
out = append(out, p.Event)
}
return out
}
func TestFireReachesEveryHandlerInOrder(t *testing.T) {
var order []string
h := New("s1")
h.Add(
HandlerFunc(func(_ context.Context, _ Payload) error {
order = append(order, "first")
return nil
}),
HandlerFunc(func(_ context.Context, _ Payload) error {
order = append(order, "second")
return nil
}),
)
h.Fire(context.Background(), Payload{Event: TurnStart})
if len(order) != 2 || order[0] != "first" || order[1] != "second" {
t.Errorf("handlers ran %v, want them in the order they were added", order)
}
}
func TestFireStampsTimeAndSession(t *testing.T) {
rec := &recorder{}
h := New("20260919T120000Z")
h.Add(rec)
h.Fire(context.Background(), Payload{Event: RunStart})
got := rec.got[0]
if got.Session != "20260919T120000Z" {
t.Errorf("session stamp is %q, want the one the hooks were built with", got.Session)
}
if got.Time.IsZero() {
t.Error("the event went out without a time on it")
}
}
// A caller that knows better than the stamp keeps its own values: a subagent's
// event is not the session's, and a replayed one is not happening now.
func TestFireKeepsWhatTheCallerSet(t *testing.T) {
rec := &recorder{}
h := New("s1")
h.Add(rec)
when := time.Date(2026, 9, 19, 12, 0, 0, 0, time.UTC)
h.Fire(context.Background(), Payload{Event: RunStart, Session: "other", Time: when})
if got := rec.got[0]; got.Session != "other" || !got.Time.Equal(when) {
t.Errorf("Fire overwrote what the caller set: session %q, time %v", got.Session, got.Time)
}
}
func TestForSharesHandlersAndChangesTheSession(t *testing.T) {
rec := &recorder{}
h := New("tab1")
h.Add(rec)
tab2 := h.For("tab2")
h.Fire(context.Background(), Payload{Event: RunStart})
tab2.Fire(context.Background(), Payload{Event: RunEnd})
if len(rec.got) != 2 {
t.Fatalf("the derived hooks reached %d events, want both on the one handler", len(rec.got))
}
if rec.got[0].Session != "tab1" || rec.got[1].Session != "tab2" {
t.Errorf("sessions are %q and %q, want tab1 then tab2", rec.got[0].Session, rec.got[1].Session)
}
}
// Registering on a derived hooks must reach the original, because the tab that
// opened second is not a second place for a reporter to be installed.
func TestForSharesLaterRegistrations(t *testing.T) {
rec := &recorder{}
h := New("tab1")
tab2 := h.For("tab2")
tab2.Add(rec)
h.Fire(context.Background(), Payload{Event: RunStart})
if len(rec.got) != 1 {
t.Fatalf("got %d events on the original, want the handler added through the derived one to hear it", len(rec.got))
}
}
func TestNilHooksDropEverything(t *testing.T) {
var h *Hooks
// The whole point of the nil case: a caller never guards these.
h.Fire(context.Background(), Payload{Event: RunStart})
h.Add(&recorder{})
h.OnError(func(Payload, error) {})
if h.Enabled() {
t.Error("nil hooks reported that something was listening")
}
if h.For("x") != nil {
t.Error("deriving from nil hooks produced something non-nil")
}
if err := h.Close(); err != nil {
t.Errorf("closing nil hooks: %v", err)
}
if h.Session() != "" {
t.Error("nil hooks claimed a session")
}
}
func TestEnabledIsFalseUntilSomethingListens(t *testing.T) {
h := New("s1")
if h.Enabled() {
t.Error("fresh hooks reported a listener")
}
h.Add(&recorder{})
if !h.Enabled() {
t.Error("hooks with a handler reported none")
}
}
func TestAddSkipsNilHandlers(t *testing.T) {
h := New("s1")
h.Add(nil)
if h.Enabled() {
t.Error("a nil handler was registered")
}
// A detector that returns a typed nil is the real shape of this: the
// caller writes hooks.Add(detect()) and has no interface to compare.
h.Add((*nilable)(nil))
if h.Enabled() {
t.Error("a typed-nil handler that says it is nil was registered")
}
h.Fire(context.Background(), Payload{Event: RunStart})
}
// nilable stands in for a detector's return value: nil-safe and able to say so.
type nilable struct{}
func (n *nilable) IsNil() bool { return n == nil }
func (n *nilable) Handle(context.Context, Payload) error {
if n == nil {
panic("a typed-nil handler was called")
}
return nil
}
func TestHandlerErrorGoesToOnErrorAndTheRestStillRun(t *testing.T) {
failing := &recorder{fail: true, err: errors.New("no")}
after := &recorder{}
var reported []error
h := New("s1")
h.OnError(func(_ Payload, err error) { reported = append(reported, err) })
h.Add(failing, after)
h.Fire(context.Background(), Payload{Event: RunStart})
if len(reported) != 1 {
t.Fatalf("OnError saw %d errors, want the one that was returned", len(reported))
}
if len(after.got) != 1 {
t.Error("a handler that failed stopped the ones after it")
}
}
// A hook runs an arbitrary command on behalf of a config file. The agent's
// goroutine is not the place to find out that the command was wrong.
func TestPanickingHandlerBecomesAnError(t *testing.T) {
var reported []error
h := New("s1")
h.OnError(func(_ Payload, err error) { reported = append(reported, err) })
h.Add(HandlerFunc(func(context.Context, Payload) error { panic("boom") }))
h.Fire(context.Background(), Payload{Event: RunStart})
if len(reported) != 1 {
t.Fatalf("a panicking handler reported %d errors, want 1", len(reported))
}
if reported[0] == nil {
t.Fatal("the panic came back as a nil error")
}
}
func TestErrorsAreDroppedWhenNothingIsListeningForThem(t *testing.T) {
h := New("s1")
h.Add(&recorder{fail: true, err: errors.New("no")})
// No OnError installed: the failure has nowhere to go and must not panic.
h.Fire(context.Background(), Payload{Event: RunStart})
}
// closer is a handler with something to release.
type closer struct {
recorder
closed int
err error
}
func (c *closer) Close() error {
c.closed++
return c.err
}
func TestCloseClosesHandlersOnceAndStopsDelivery(t *testing.T) {
c := &closer{}
h := New("s1")
h.Add(c)
if err := h.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if err := h.Close(); err != nil {
t.Fatalf("second Close: %v", err)
}
if c.closed != 1 {
t.Errorf("handler was closed %d times, want once", c.closed)
}
h.Fire(context.Background(), Payload{Event: RunEnd})
if len(c.got) != 0 {
t.Error("an event was delivered after Close")
}
}
func TestCloseReportsTheFirstFailure(t *testing.T) {
want := errors.New("release failed")
h := New("s1")
h.Add(&closer{err: want})
if err := h.Close(); !errors.Is(err, want) {
t.Errorf("Close returned %v, want the handler's own error", err)
}
}
func TestFireToleratesANilContext(t *testing.T) {
rec := &recorder{}
h := New("s1")
h.Add(rec)
//lint:ignore SA1012 the point is that a caller passing nil is survivable
h.Fire(nil, Payload{Event: RunStart}) //nolint:staticcheck
if len(rec.got) != 1 {
t.Error("a nil context swallowed the event")
}
}
func TestFireIsSafeFromSeveralGoroutines(t *testing.T) {
rec := &recorder{}
h := New("s1")
h.Add(rec)
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
h.Fire(context.Background(), Payload{Event: TurnStart})
}()
}
wg.Wait()
if len(rec.got) != 20 {
t.Errorf("recorded %d events, want every one of the 20", len(rec.got))
}
}
func TestKnownCoversEveryEventFiredAndNothingElse(t *testing.T) {
for _, e := range All {
if !Known(e) {
t.Errorf("%s is in All but Known says otherwise", e)
}
}
if Known("turn_started") {
t.Error("a misspelt event was accepted as known")
}
}