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") } }