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.
173 lines
4.2 KiB
Go
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)
|
|
}
|
|
}
|