Files
zzg 066485daf7 Surface cached tokens, fold permission-wait spans, fix clipped durations
Running a real Claude Code session against Fathom exposed three things that
only show up on real data:

- A run measured 16 input and 1,301 output tokens against 280,054 cache
  reads. Summing them reports "281k" and makes every cached run look huge;
  omitting them reports "1.3k" and hides where the context came from. Both
  are now tracked and shown separately.
- Folding required a matching tool_use_id, but Claude Code stamps that on
  the tool and tool.execution spans and omits it on tool.blocked_on_user, so
  a permission-wait row survived under every tool. The test is now
  containment plus a dotted name extension, folding 29 spans into 17 rows.
- Span durations were absolutely positioned past the end of their bar, so a
  full-width bar pushed its label off-screen; percentage geometry resolves
  against the padding box, so padding reserved nothing. Durations moved to
  their own grid column, which also aligns them.

Adds config tests, an annotated fathom.ini.example, and a test that runs
node --check over the embedded JS -- a mangled edit shipped a blank page
once, and nothing else validates JS that has no build step.
2026-09-15 22:01:54 -07:00

173 lines
4.2 KiB
Go

package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func writeConfig(t *testing.T, body string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "fathom.ini")
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return p
}
func TestDefaults(t *testing.T) {
c, err := Load("")
if err != nil {
t.Fatal(err)
}
if c.Server.Addr != ":3000" {
t.Errorf("default addr = %q, want :3000", c.Server.Addr)
}
if c.Storage.Dir != "./data" {
t.Errorf("default dir = %q", c.Storage.Dir)
}
// Identity attributes must default to dropped: a default that leaks the
// operator's email into every shard is the wrong way round.
if c.Privacy.KeepIdentityAttrs {
t.Error("KeepIdentityAttrs defaults to true; it must default to false")
}
}
func TestMissingFileIsNotAnError(t *testing.T) {
// A single-binary deployment must run with no config at all.
c, err := Load(filepath.Join(t.TempDir(), "nope.ini"))
if err != nil {
t.Fatalf("a missing config file should fall back to defaults: %v", err)
}
if c.Server.Addr != ":3000" {
t.Errorf("addr = %q", c.Server.Addr)
}
}
func TestParseFile(t *testing.T) {
p := writeConfig(t, `
# a comment
; another comment
[server]
addr = :8080
[storage]
dir = /srv/fathom
flush_rows = 500
flush_interval = 2s
retention_days = 14
max_bytes = 10GB
[privacy]
keep_identity_attrs = yes
max_blob_bytes = 256KB
`)
c, err := Load(p)
if err != nil {
t.Fatal(err)
}
if c.Server.Addr != ":8080" {
t.Errorf("addr = %q", c.Server.Addr)
}
if c.Storage.Dir != "/srv/fathom" {
t.Errorf("dir = %q", c.Storage.Dir)
}
if c.Storage.FlushRows != 500 {
t.Errorf("flush_rows = %d", c.Storage.FlushRows)
}
if c.Storage.FlushInterval != 2*time.Second {
t.Errorf("flush_interval = %v", c.Storage.FlushInterval)
}
if c.Storage.RetentionDays != 14 {
t.Errorf("retention_days = %d", c.Storage.RetentionDays)
}
if c.Storage.MaxBytes != 10<<30 {
t.Errorf("max_bytes = %d, want %d", c.Storage.MaxBytes, int64(10)<<30)
}
if !c.Privacy.KeepIdentityAttrs {
t.Error("keep_identity_attrs = yes was not applied")
}
if c.Privacy.MaxBlobBytes != 256<<10 {
t.Errorf("max_blob_bytes = %d", c.Privacy.MaxBlobBytes)
}
}
// TestUnknownKeyIsReported is the behaviour a silent config parser gets wrong.
// A typo that is ignored means the setting you thought you changed never took
// effect, and nothing says so.
func TestUnknownKeyIsReported(t *testing.T) {
p := writeConfig(t, "[storage]\nretention_dayz = 7\n")
_, err := Load(p)
if err == nil {
t.Fatal("a misspelled key was accepted silently")
}
if !strings.Contains(err.Error(), "retention_dayz") {
t.Errorf("error %q should name the offending key", err)
}
}
func TestMalformedLineIsReported(t *testing.T) {
p := writeConfig(t, "[server]\nthis is not a setting\n")
_, err := Load(p)
if err == nil {
t.Fatal("a malformed line was accepted")
}
if !strings.Contains(err.Error(), ":2") {
t.Errorf("error %q should carry the line number", err)
}
}
func TestEnvOverridesFile(t *testing.T) {
p := writeConfig(t, "[server]\naddr = :8080\n[storage]\ndir = /from/file\n")
t.Setenv("FATHOM_ADDR", ":9999")
t.Setenv("FATHOM_DATA_DIR", "/from/env")
c, err := Load(p)
if err != nil {
t.Fatal(err)
}
if c.Server.Addr != ":9999" {
t.Errorf("addr = %q, want the environment to win", c.Server.Addr)
}
if c.Storage.Dir != "/from/env" {
t.Errorf("dir = %q, want the environment to win", c.Storage.Dir)
}
}
func TestParseBytes(t *testing.T) {
cases := map[string]int64{
"1024": 1024, "1KB": 1 << 10, "1k": 1 << 10,
"5MB": 5 << 20, "2GB": 2 << 30, "1TB": 1 << 40,
"512B": 512, "1.5GB": int64(1.5 * float64(int64(1)<<30)),
}
for in, want := range cases {
got, err := parseBytes(in)
if err != nil {
t.Errorf("parseBytes(%q): %v", in, err)
continue
}
if got != want {
t.Errorf("parseBytes(%q) = %d, want %d", in, got, want)
}
}
if _, err := parseBytes("banana"); err == nil {
t.Error("parseBytes accepted a non-number")
}
}
func TestQuotedValues(t *testing.T) {
p := writeConfig(t, `[storage]
dir = "/path/with spaces"
`)
c, err := Load(p)
if err != nil {
t.Fatal(err)
}
if c.Storage.Dir != "/path/with spaces" {
t.Errorf("dir = %q, quotes should be stripped", c.Storage.Dir)
}
}