Vectrum
Sign In
307 lines · 7.7 KB · Go
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")
	}
}