Files
zzgandClaude Opus 5 79fe343be5 Move the module path to git.z.gd/zzg/fathom
The module was declared as github.com/zzg/fathom, which is not where
this repo lives and never has been. Nothing is published at that path,
so `go get github.com/zzg/fathom` resolves to a 404 while the import
paths in every file assert otherwise.

git.z.gd is a real module path rather than a cosmetic correction:
Gitea serves the go-import meta tag for it,

  <meta name="go-import" content="git.z.gd/zzg/fathom git https://git.z.gd/zzg/fathom.git">

and the repo answers unauthenticated over HTTPS, so `go get
git.z.gd/zzg/fathom` resolves where the old path could not.

Mechanical: the module line plus 46 import paths across 23 files. There
is no go.sum to churn, since fathom has no third-party dependencies.
go build, go vet, gofmt and the full test suite are clean under the new
path, and the built binary still runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:31:18 -07:00

190 lines
4.8 KiB
Go

package web
import (
"bufio"
"context"
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.z.gd/zzg/fathom/internal/ingest"
"git.z.gd/zzg/fathom/internal/model"
"git.z.gd/zzg/fathom/internal/store"
)
// liveServer wires a hub to a store the way cmd/fathom does, so the test
// exercises the real path from flush to browser rather than calling the hub
// directly.
func liveServer(t *testing.T) (*httptest.Server, *store.Store, *Hub) {
t.Helper()
hub := NewHub()
st, err := store.Open(store.Options{
Dir: t.TempDir(), FlushInterval: time.Hour, FlushRows: 1 << 20,
OnFlush: hub.NotifyFlush,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
srv := NewServer(st, ingest.NewDecoder(), hub, log.New(io.Discard, "", 0))
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return ts, st, hub
}
// readEvent reads SSE frames until it finds a data line, or times out.
func readEvent(t *testing.T, r *bufio.Reader) Event {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
line, err := r.ReadString('\n')
if err != nil {
t.Fatalf("reading event stream: %v", err)
}
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "data: ") {
continue // comment, retry directive, or blank separator
}
var ev Event
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &ev); err != nil {
t.Fatalf("event is not JSON: %v", err)
}
return ev
}
t.Fatal("timed out waiting for an event")
return Event{}
}
func TestEventStreamAnnouncesFlush(t *testing.T) {
ts, st, hub := liveServer(t)
res, err := http.Get(ts.URL + "/api/events")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if ct := res.Header.Get("Content-Type"); ct != "text/event-stream" {
t.Fatalf("Content-Type = %q, want text/event-stream", ct)
}
r := bufio.NewReader(res.Body)
// The stream opens with a hello so the UI can show "connected" immediately
// rather than only after the first trace arrives.
if ev := readEvent(t, r); ev.Type != "hello" {
t.Fatalf("first event is %q, want hello", ev.Type)
}
// Wait for the handler to register before flushing, or the event is
// broadcast to nobody.
waitFor(t, func() bool { return hub.Clients() == 1 })
base := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC).UnixMicro()
if err := st.AddSpans([]model.Span{{
TraceID: "t-live", SpanID: "s1", Name: "x",
StartTS: base, EndTS: base + 1000, SampleRate: 1,
}}); err != nil {
t.Fatal(err)
}
if err := st.Flush(); err != nil {
t.Fatal(err)
}
ev := readEvent(t, r)
if ev.Type != "flush" {
t.Fatalf("event type = %q, want flush", ev.Type)
}
if ev.Spans != 1 {
t.Errorf("event reports %d spans, want 1", ev.Spans)
}
// The trace id is what lets the detail view refetch only when the trace on
// screen actually changed.
if len(ev.TraceIDs) != 1 || ev.TraceIDs[0] != "t-live" {
t.Errorf("trace_ids = %v, want [t-live]", ev.TraceIDs)
}
if ev.Seq == 0 {
t.Error("event has no sequence number")
}
}
// TestNoEventForEmptyFlush guards the quiet case: an idle server calls Flush on
// a timer, and a notification per tick would make the UI refetch forever for
// nothing.
func TestNoEventForEmptyFlush(t *testing.T) {
var got int
st, err := store.Open(store.Options{
Dir: t.TempDir(), FlushInterval: time.Hour, FlushRows: 1 << 20,
OnFlush: func(store.FlushInfo) { got++ },
})
if err != nil {
t.Fatal(err)
}
defer st.Close()
for i := 0; i < 5; i++ {
if err := st.Flush(); err != nil {
t.Fatal(err)
}
}
if got != 0 {
t.Errorf("empty flushes produced %d notifications, want 0", got)
}
}
// TestSlowClientDoesNotBlockIngest is the property that keeps a stalled browser
// tab from becoming a stalled ingest path.
func TestSlowClientDoesNotBlockIngest(t *testing.T) {
hub := NewHub()
stuck := make(chan []byte) // unbuffered and never read
hub.add(stuck)
done := make(chan struct{})
go func() {
for i := 0; i < 100; i++ {
hub.NotifyFlush(store.FlushInfo{Spans: 1, TraceIDs: []string{"t"}})
}
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("a client that never reads blocked the flush path")
}
}
func TestClientDisconnectIsCleanedUp(t *testing.T) {
ts, _, hub := liveServer(t)
req, _ := http.NewRequest("GET", ts.URL+"/api/events", nil)
ctx, cancel := context.WithCancel(context.Background())
res, err := http.DefaultClient.Do(req.WithContext(ctx))
if err != nil {
t.Fatal(err)
}
waitFor(t, func() bool { return hub.Clients() == 1 })
cancel()
res.Body.Close()
waitFor(t, func() bool { return hub.Clients() == 0 })
}
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("condition never became true")
}