test: add coverage for lab, session, sigil, repos, plugin packages

Brings 5 packages from low/zero coverage to solid test suites:
- pkg/lab: 0% → 100% (Store pub/sub, Config env loading)
- pkg/session: 0% → 89.9% (transcript parser, HTML renderer, search, video)
- pkg/io/sigil: 43.8% → 98.5% (XOR/ShuffleMask obfuscators, ChaCha20-Poly1305)
- pkg/repos: 18.9% → 81.9% (registry, topo sort, directory scan, org detection)
- pkg/plugin: 54.8% → 67.1% (installer error paths, Remove, registry Load/Save)

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-24 13:29:15 +00:00
parent 5a92bd652b
commit 3587d0ce27
10 changed files with 2680 additions and 7 deletions

View file

@ -0,0 +1,536 @@
package sigil
import (
"bytes"
"crypto/rand"
"errors"
"io"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ── XORObfuscator ──────────────────────────────────────────────────
func TestXORObfuscator_Good_RoundTrip(t *testing.T) {
ob := &XORObfuscator{}
data := []byte("the axioms are in the weights")
entropy := []byte("deterministic-nonce-24bytes!")
obfuscated := ob.Obfuscate(data, entropy)
assert.NotEqual(t, data, obfuscated)
assert.Len(t, obfuscated, len(data))
restored := ob.Deobfuscate(obfuscated, entropy)
assert.Equal(t, data, restored)
}
func TestXORObfuscator_Good_DifferentEntropyDifferentOutput(t *testing.T) {
ob := &XORObfuscator{}
data := []byte("same plaintext")
out1 := ob.Obfuscate(data, []byte("entropy-a"))
out2 := ob.Obfuscate(data, []byte("entropy-b"))
assert.NotEqual(t, out1, out2)
}
func TestXORObfuscator_Good_Deterministic(t *testing.T) {
ob := &XORObfuscator{}
data := []byte("reproducible")
entropy := []byte("fixed-seed")
out1 := ob.Obfuscate(data, entropy)
out2 := ob.Obfuscate(data, entropy)
assert.Equal(t, out1, out2)
}
func TestXORObfuscator_Good_LargeData(t *testing.T) {
ob := &XORObfuscator{}
// Larger than one SHA-256 block (32 bytes) to test multi-block key stream.
data := make([]byte, 256)
for i := range data {
data[i] = byte(i)
}
entropy := []byte("test-entropy")
obfuscated := ob.Obfuscate(data, entropy)
restored := ob.Deobfuscate(obfuscated, entropy)
assert.Equal(t, data, restored)
}
func TestXORObfuscator_Good_EmptyData(t *testing.T) {
ob := &XORObfuscator{}
result := ob.Obfuscate([]byte{}, []byte("entropy"))
assert.Equal(t, []byte{}, result)
result = ob.Deobfuscate([]byte{}, []byte("entropy"))
assert.Equal(t, []byte{}, result)
}
func TestXORObfuscator_Good_SymmetricProperty(t *testing.T) {
ob := &XORObfuscator{}
data := []byte("XOR is its own inverse")
entropy := []byte("nonce")
// XOR is symmetric: Obfuscate(Obfuscate(x)) == x
double := ob.Obfuscate(ob.Obfuscate(data, entropy), entropy)
assert.Equal(t, data, double)
}
// ── ShuffleMaskObfuscator ──────────────────────────────────────────
func TestShuffleMaskObfuscator_Good_RoundTrip(t *testing.T) {
ob := &ShuffleMaskObfuscator{}
data := []byte("shuffle and mask protect patterns")
entropy := []byte("deterministic-entropy")
obfuscated := ob.Obfuscate(data, entropy)
assert.NotEqual(t, data, obfuscated)
assert.Len(t, obfuscated, len(data))
restored := ob.Deobfuscate(obfuscated, entropy)
assert.Equal(t, data, restored)
}
func TestShuffleMaskObfuscator_Good_DifferentEntropy(t *testing.T) {
ob := &ShuffleMaskObfuscator{}
data := []byte("same data")
out1 := ob.Obfuscate(data, []byte("entropy-1"))
out2 := ob.Obfuscate(data, []byte("entropy-2"))
assert.NotEqual(t, out1, out2)
}
func TestShuffleMaskObfuscator_Good_Deterministic(t *testing.T) {
ob := &ShuffleMaskObfuscator{}
data := []byte("reproducible shuffle")
entropy := []byte("fixed")
out1 := ob.Obfuscate(data, entropy)
out2 := ob.Obfuscate(data, entropy)
assert.Equal(t, out1, out2)
}
func TestShuffleMaskObfuscator_Good_LargeData(t *testing.T) {
ob := &ShuffleMaskObfuscator{}
data := make([]byte, 512)
for i := range data {
data[i] = byte(i % 256)
}
entropy := []byte("large-data-test")
obfuscated := ob.Obfuscate(data, entropy)
restored := ob.Deobfuscate(obfuscated, entropy)
assert.Equal(t, data, restored)
}
func TestShuffleMaskObfuscator_Good_EmptyData(t *testing.T) {
ob := &ShuffleMaskObfuscator{}
result := ob.Obfuscate([]byte{}, []byte("entropy"))
assert.Equal(t, []byte{}, result)
result = ob.Deobfuscate([]byte{}, []byte("entropy"))
assert.Equal(t, []byte{}, result)
}
func TestShuffleMaskObfuscator_Good_SingleByte(t *testing.T) {
ob := &ShuffleMaskObfuscator{}
data := []byte{0x42}
entropy := []byte("single")
obfuscated := ob.Obfuscate(data, entropy)
restored := ob.Deobfuscate(obfuscated, entropy)
assert.Equal(t, data, restored)
}
// ── NewChaChaPolySigil ─────────────────────────────────────────────
func TestNewChaChaPolySigil_Good(t *testing.T) {
key := make([]byte, 32)
_, _ = rand.Read(key)
s, err := NewChaChaPolySigil(key)
require.NoError(t, err)
assert.NotNil(t, s)
assert.Equal(t, key, s.Key)
assert.NotNil(t, s.Obfuscator)
}
func TestNewChaChaPolySigil_Good_KeyIsCopied(t *testing.T) {
key := make([]byte, 32)
_, _ = rand.Read(key)
original := make([]byte, 32)
copy(original, key)
s, err := NewChaChaPolySigil(key)
require.NoError(t, err)
// Mutating the original key should not affect the sigil.
key[0] ^= 0xFF
assert.Equal(t, original, s.Key)
}
func TestNewChaChaPolySigil_Bad_ShortKey(t *testing.T) {
_, err := NewChaChaPolySigil([]byte("too short"))
assert.ErrorIs(t, err, ErrInvalidKey)
}
func TestNewChaChaPolySigil_Bad_LongKey(t *testing.T) {
_, err := NewChaChaPolySigil(make([]byte, 64))
assert.ErrorIs(t, err, ErrInvalidKey)
}
func TestNewChaChaPolySigil_Bad_EmptyKey(t *testing.T) {
_, err := NewChaChaPolySigil(nil)
assert.ErrorIs(t, err, ErrInvalidKey)
}
// ── NewChaChaPolySigilWithObfuscator ───────────────────────────────
func TestNewChaChaPolySigilWithObfuscator_Good(t *testing.T) {
key := make([]byte, 32)
_, _ = rand.Read(key)
ob := &ShuffleMaskObfuscator{}
s, err := NewChaChaPolySigilWithObfuscator(key, ob)
require.NoError(t, err)
assert.Equal(t, ob, s.Obfuscator)
}
func TestNewChaChaPolySigilWithObfuscator_Good_NilObfuscator(t *testing.T) {
key := make([]byte, 32)
_, _ = rand.Read(key)
s, err := NewChaChaPolySigilWithObfuscator(key, nil)
require.NoError(t, err)
// Falls back to default XORObfuscator.
assert.IsType(t, &XORObfuscator{}, s.Obfuscator)
}
func TestNewChaChaPolySigilWithObfuscator_Bad_InvalidKey(t *testing.T) {
_, err := NewChaChaPolySigilWithObfuscator([]byte("bad"), &XORObfuscator{})
assert.ErrorIs(t, err, ErrInvalidKey)
}
// ── ChaChaPolySigil In/Out (encrypt/decrypt) ───────────────────────
func TestChaChaPolySigil_Good_RoundTrip(t *testing.T) {
key := make([]byte, 32)
_, _ = rand.Read(key)
s, err := NewChaChaPolySigil(key)
require.NoError(t, err)
plaintext := []byte("consciousness does not merely avoid causing harm")
ciphertext, err := s.In(plaintext)
require.NoError(t, err)
assert.NotEqual(t, plaintext, ciphertext)
assert.Greater(t, len(ciphertext), len(plaintext)) // nonce + tag overhead
decrypted, err := s.Out(ciphertext)
require.NoError(t, err)
assert.Equal(t, plaintext, decrypted)
}
func TestChaChaPolySigil_Good_WithShuffleMask(t *testing.T) {
key := make([]byte, 32)
_, _ = rand.Read(key)
s, err := NewChaChaPolySigilWithObfuscator(key, &ShuffleMaskObfuscator{})
require.NoError(t, err)
plaintext := []byte("shuffle mask pre-obfuscation layer")
ciphertext, err := s.In(plaintext)
require.NoError(t, err)
decrypted, err := s.Out(ciphertext)
require.NoError(t, err)
assert.Equal(t, plaintext, decrypted)
}
func TestChaChaPolySigil_Good_NilData(t *testing.T) {
key := make([]byte, 32)
_, _ = rand.Read(key)
s, err := NewChaChaPolySigil(key)
require.NoError(t, err)
enc, err := s.In(nil)
require.NoError(t, err)
assert.Nil(t, enc)
dec, err := s.Out(nil)
require.NoError(t, err)
assert.Nil(t, dec)
}
func TestChaChaPolySigil_Good_EmptyPlaintext(t *testing.T) {
key := make([]byte, 32)
_, _ = rand.Read(key)
s, err := NewChaChaPolySigil(key)
require.NoError(t, err)
ciphertext, err := s.In([]byte{})
require.NoError(t, err)
assert.NotEmpty(t, ciphertext) // Has nonce + tag even for empty plaintext.
decrypted, err := s.Out(ciphertext)
require.NoError(t, err)
assert.Equal(t, []byte{}, decrypted)
}
func TestChaChaPolySigil_Good_DifferentCiphertextsPerCall(t *testing.T) {
key := make([]byte, 32)
_, _ = rand.Read(key)
s, err := NewChaChaPolySigil(key)
require.NoError(t, err)
plaintext := []byte("same input")
ct1, _ := s.In(plaintext)
ct2, _ := s.In(plaintext)
// Different nonces → different ciphertexts.
assert.NotEqual(t, ct1, ct2)
}
func TestChaChaPolySigil_Bad_NoKey(t *testing.T) {
s := &ChaChaPolySigil{}
_, err := s.In([]byte("data"))
assert.ErrorIs(t, err, ErrNoKeyConfigured)
_, err = s.Out([]byte("data"))
assert.ErrorIs(t, err, ErrNoKeyConfigured)
}
func TestChaChaPolySigil_Bad_WrongKey(t *testing.T) {
key1 := make([]byte, 32)
key2 := make([]byte, 32)
_, _ = rand.Read(key1)
_, _ = rand.Read(key2)
s1, _ := NewChaChaPolySigil(key1)
s2, _ := NewChaChaPolySigil(key2)
ciphertext, err := s1.In([]byte("secret"))
require.NoError(t, err)
_, err = s2.Out(ciphertext)
assert.ErrorIs(t, err, ErrDecryptionFailed)
}
func TestChaChaPolySigil_Bad_TruncatedCiphertext(t *testing.T) {
key := make([]byte, 32)
_, _ = rand.Read(key)
s, _ := NewChaChaPolySigil(key)
_, err := s.Out([]byte("too short"))
assert.ErrorIs(t, err, ErrCiphertextTooShort)
}
func TestChaChaPolySigil_Bad_TamperedCiphertext(t *testing.T) {
key := make([]byte, 32)
_, _ = rand.Read(key)
s, _ := NewChaChaPolySigil(key)
ciphertext, _ := s.In([]byte("authentic data"))
// Flip a bit in the ciphertext body (after nonce).
ciphertext[30] ^= 0xFF
_, err := s.Out(ciphertext)
assert.ErrorIs(t, err, ErrDecryptionFailed)
}
// failReader returns an error on read — for testing nonce generation failure.
type failReader struct{}
func (f *failReader) Read([]byte) (int, error) {
return 0, errors.New("entropy source failed")
}
func TestChaChaPolySigil_Bad_RandReaderFailure(t *testing.T) {
key := make([]byte, 32)
_, _ = rand.Read(key)
s, _ := NewChaChaPolySigil(key)
s.randReader = &failReader{}
_, err := s.In([]byte("data"))
assert.Error(t, err)
}
// ── ChaChaPolySigil without obfuscator ─────────────────────────────
func TestChaChaPolySigil_Good_NoObfuscator(t *testing.T) {
key := make([]byte, 32)
_, _ = rand.Read(key)
s, _ := NewChaChaPolySigil(key)
s.Obfuscator = nil // Disable pre-obfuscation.
plaintext := []byte("raw encryption without pre-obfuscation")
ciphertext, err := s.In(plaintext)
require.NoError(t, err)
decrypted, err := s.Out(ciphertext)
require.NoError(t, err)
assert.Equal(t, plaintext, decrypted)
}
// ── GetNonceFromCiphertext ─────────────────────────────────────────
func TestGetNonceFromCiphertext_Good(t *testing.T) {
key := make([]byte, 32)
_, _ = rand.Read(key)
s, _ := NewChaChaPolySigil(key)
ciphertext, _ := s.In([]byte("nonce extraction test"))
nonce, err := GetNonceFromCiphertext(ciphertext)
require.NoError(t, err)
assert.Len(t, nonce, 24) // XChaCha20 nonce is 24 bytes.
// Nonce should match the prefix of the ciphertext.
assert.Equal(t, ciphertext[:24], nonce)
}
func TestGetNonceFromCiphertext_Good_NonceCopied(t *testing.T) {
key := make([]byte, 32)
_, _ = rand.Read(key)
s, _ := NewChaChaPolySigil(key)
ciphertext, _ := s.In([]byte("data"))
nonce, _ := GetNonceFromCiphertext(ciphertext)
original := make([]byte, len(nonce))
copy(original, nonce)
// Mutating the nonce should not affect the ciphertext.
nonce[0] ^= 0xFF
assert.Equal(t, original, ciphertext[:24])
}
func TestGetNonceFromCiphertext_Bad_TooShort(t *testing.T) {
_, err := GetNonceFromCiphertext([]byte("short"))
assert.ErrorIs(t, err, ErrCiphertextTooShort)
}
func TestGetNonceFromCiphertext_Bad_Empty(t *testing.T) {
_, err := GetNonceFromCiphertext(nil)
assert.ErrorIs(t, err, ErrCiphertextTooShort)
}
// ── ChaChaPolySigil in Transmute pipeline ──────────────────────────
func TestChaChaPolySigil_Good_InTransmutePipeline(t *testing.T) {
key := make([]byte, 32)
_, _ = rand.Read(key)
s, _ := NewChaChaPolySigil(key)
hexSigil, _ := NewSigil("hex")
chain := []Sigil{s, hexSigil}
plaintext := []byte("encrypt then hex encode")
encoded, err := Transmute(plaintext, chain)
require.NoError(t, err)
// Result should be hex-encoded ciphertext.
assert.True(t, isHex(encoded))
decoded, err := Untransmute(encoded, chain)
require.NoError(t, err)
assert.Equal(t, plaintext, decoded)
}
func isHex(data []byte) bool {
for _, b := range data {
if !((b >= '0' && b <= '9') || (b >= 'a' && b <= 'f')) {
return false
}
}
return len(data) > 0
}
// ── Transmute error propagation ────────────────────────────────────
type failSigil struct{}
func (f *failSigil) In([]byte) ([]byte, error) { return nil, errors.New("fail in") }
func (f *failSigil) Out([]byte) ([]byte, error) { return nil, errors.New("fail out") }
func TestTransmute_Bad_ErrorPropagation(t *testing.T) {
_, err := Transmute([]byte("data"), []Sigil{&failSigil{}})
assert.Error(t, err)
assert.Contains(t, err.Error(), "fail in")
}
func TestUntransmute_Bad_ErrorPropagation(t *testing.T) {
_, err := Untransmute([]byte("data"), []Sigil{&failSigil{}})
assert.Error(t, err)
assert.Contains(t, err.Error(), "fail out")
}
// ── GzipSigil with custom writer (edge case) ──────────────────────
func TestGzipSigil_Good_CustomWriter(t *testing.T) {
var buf bytes.Buffer
s := &GzipSigil{writer: &buf}
// With custom writer, compressed data goes to buf, returned bytes will be empty
// because the internal buffer 'b' is unused when s.writer is set.
_, err := s.In([]byte("test data"))
require.NoError(t, err)
assert.Greater(t, buf.Len(), 0)
}
// ── deriveKeyStream edge: exactly 32 bytes ─────────────────────────
func TestDeriveKeyStream_Good_ExactBlockSize(t *testing.T) {
ob := &XORObfuscator{}
data := make([]byte, 32) // Exactly one SHA-256 block.
for i := range data {
data[i] = byte(i)
}
entropy := []byte("block-boundary")
obfuscated := ob.Obfuscate(data, entropy)
restored := ob.Deobfuscate(obfuscated, entropy)
assert.Equal(t, data, restored)
}
// ── io.Reader fallback in In ───────────────────────────────────────
func TestChaChaPolySigil_Good_NilRandReader(t *testing.T) {
key := make([]byte, 32)
_, _ = rand.Read(key)
s, _ := NewChaChaPolySigil(key)
s.randReader = nil // Should fall back to crypto/rand.Reader.
ciphertext, err := s.In([]byte("fallback reader"))
require.NoError(t, err)
decrypted, err := s.Out(ciphertext)
require.NoError(t, err)
assert.Equal(t, []byte("fallback reader"), decrypted)
}
// limitReader returns exactly N bytes then EOF — for deterministic tests.
type limitReader struct {
data []byte
pos int
}
func (l *limitReader) Read(p []byte) (int, error) {
if l.pos >= len(l.data) {
return 0, io.EOF
}
n := copy(p, l.data[l.pos:])
l.pos += n
return n, nil
}

129
pkg/lab/config_test.go Normal file
View file

@ -0,0 +1,129 @@
package lab
import (
"os"
"testing"
)
// ── LoadConfig defaults ────────────────────────────────────────────
func TestLoadConfig_Good_Defaults(t *testing.T) {
cfg := LoadConfig()
if cfg.Addr != ":8080" {
t.Fatalf("expected :8080, got %s", cfg.Addr)
}
if cfg.PrometheusURL != "http://prometheus:9090" {
t.Fatalf("unexpected PrometheusURL: %s", cfg.PrometheusURL)
}
if cfg.PrometheusInterval != 15 {
t.Fatalf("expected 15, got %d", cfg.PrometheusInterval)
}
if cfg.ForgeURL != "https://forge.lthn.io" {
t.Fatalf("unexpected ForgeURL: %s", cfg.ForgeURL)
}
if cfg.ForgeInterval != 60 {
t.Fatalf("expected 60, got %d", cfg.ForgeInterval)
}
if cfg.HFAuthor != "lthn" {
t.Fatalf("expected lthn, got %s", cfg.HFAuthor)
}
if cfg.HFInterval != 300 {
t.Fatalf("expected 300, got %d", cfg.HFInterval)
}
if cfg.TrainingDataDir != "/data/training" {
t.Fatalf("unexpected TrainingDataDir: %s", cfg.TrainingDataDir)
}
if cfg.InfluxDB != "training" {
t.Fatalf("expected training, got %s", cfg.InfluxDB)
}
}
// ── env override ───────────────────────────────────────────────────
func TestLoadConfig_Good_EnvOverride(t *testing.T) {
os.Setenv("ADDR", ":9090")
os.Setenv("FORGE_URL", "https://forge.lthn.ai")
os.Setenv("HF_AUTHOR", "snider")
defer func() {
os.Unsetenv("ADDR")
os.Unsetenv("FORGE_URL")
os.Unsetenv("HF_AUTHOR")
}()
cfg := LoadConfig()
if cfg.Addr != ":9090" {
t.Fatalf("expected :9090, got %s", cfg.Addr)
}
if cfg.ForgeURL != "https://forge.lthn.ai" {
t.Fatalf("expected forge.lthn.ai, got %s", cfg.ForgeURL)
}
if cfg.HFAuthor != "snider" {
t.Fatalf("expected snider, got %s", cfg.HFAuthor)
}
}
// ── envInt ─────────────────────────────────────────────────────────
func TestLoadConfig_Good_IntEnvOverride(t *testing.T) {
os.Setenv("PROMETHEUS_INTERVAL", "30")
defer os.Unsetenv("PROMETHEUS_INTERVAL")
cfg := LoadConfig()
if cfg.PrometheusInterval != 30 {
t.Fatalf("expected 30, got %d", cfg.PrometheusInterval)
}
}
func TestLoadConfig_Bad_InvalidIntFallsBack(t *testing.T) {
os.Setenv("PROMETHEUS_INTERVAL", "not-a-number")
defer os.Unsetenv("PROMETHEUS_INTERVAL")
cfg := LoadConfig()
if cfg.PrometheusInterval != 15 {
t.Fatalf("expected fallback 15, got %d", cfg.PrometheusInterval)
}
}
// ── env / envInt helpers directly ──────────────────────────────────
func TestEnv_Good(t *testing.T) {
os.Setenv("TEST_LAB_KEY", "hello")
defer os.Unsetenv("TEST_LAB_KEY")
if got := env("TEST_LAB_KEY", "default"); got != "hello" {
t.Fatalf("expected hello, got %s", got)
}
}
func TestEnv_Good_Fallback(t *testing.T) {
os.Unsetenv("TEST_LAB_MISSING")
if got := env("TEST_LAB_MISSING", "fallback"); got != "fallback" {
t.Fatalf("expected fallback, got %s", got)
}
}
func TestEnvInt_Good(t *testing.T) {
os.Setenv("TEST_LAB_INT", "42")
defer os.Unsetenv("TEST_LAB_INT")
if got := envInt("TEST_LAB_INT", 0); got != 42 {
t.Fatalf("expected 42, got %d", got)
}
}
func TestEnvInt_Bad_Fallback(t *testing.T) {
os.Unsetenv("TEST_LAB_INT_MISSING")
if got := envInt("TEST_LAB_INT_MISSING", 99); got != 99 {
t.Fatalf("expected 99, got %d", got)
}
}
func TestEnvInt_Bad_InvalidString(t *testing.T) {
os.Setenv("TEST_LAB_INT_BAD", "xyz")
defer os.Unsetenv("TEST_LAB_INT_BAD")
if got := envInt("TEST_LAB_INT_BAD", 7); got != 7 {
t.Fatalf("expected fallback 7, got %d", got)
}
}

391
pkg/lab/store_test.go Normal file
View file

@ -0,0 +1,391 @@
package lab
import (
"errors"
"testing"
"time"
)
// ── NewStore ────────────────────────────────────────────────────────
func TestNewStore_Good(t *testing.T) {
s := NewStore()
if s == nil {
t.Fatal("NewStore returned nil")
}
if s.subs == nil {
t.Fatal("subs map not initialised")
}
if s.errors == nil {
t.Fatal("errors map not initialised")
}
}
// ── Subscribe / Unsubscribe ────────────────────────────────────────
func TestSubscribe_Good(t *testing.T) {
s := NewStore()
ch := s.Subscribe()
if ch == nil {
t.Fatal("Subscribe returned nil channel")
}
s.subMu.Lock()
_, ok := s.subs[ch]
s.subMu.Unlock()
if !ok {
t.Fatal("subscriber not registered")
}
}
func TestUnsubscribe_Good(t *testing.T) {
s := NewStore()
ch := s.Subscribe()
s.Unsubscribe(ch)
s.subMu.Lock()
_, ok := s.subs[ch]
s.subMu.Unlock()
if ok {
t.Fatal("subscriber not removed after Unsubscribe")
}
}
func TestUnsubscribe_Bad_NeverSubscribed(t *testing.T) {
s := NewStore()
ch := make(chan struct{}, 1)
// Should not panic.
s.Unsubscribe(ch)
}
// ── Notify ─────────────────────────────────────────────────────────
func TestNotify_Good_SubscriberReceivesSignal(t *testing.T) {
s := NewStore()
ch := s.Subscribe()
defer s.Unsubscribe(ch)
s.SetMachines([]Machine{{Name: "test"}})
select {
case <-ch:
// good
case <-time.After(100 * time.Millisecond):
t.Fatal("subscriber did not receive notification")
}
}
func TestNotify_Good_NonBlockingWhenFull(t *testing.T) {
s := NewStore()
ch := s.Subscribe()
defer s.Unsubscribe(ch)
// Fill the buffer.
ch <- struct{}{}
// Should not block.
s.SetMachines([]Machine{{Name: "a"}})
s.SetMachines([]Machine{{Name: "b"}})
}
func TestNotify_Good_MultipleSubscribers(t *testing.T) {
s := NewStore()
ch1 := s.Subscribe()
ch2 := s.Subscribe()
defer s.Unsubscribe(ch1)
defer s.Unsubscribe(ch2)
s.SetAgents(AgentSummary{Available: true})
for _, ch := range []chan struct{}{ch1, ch2} {
select {
case <-ch:
case <-time.After(100 * time.Millisecond):
t.Fatal("subscriber missed notification")
}
}
}
// ── SetMachines / Overview ─────────────────────────────────────────
func TestSetMachines_Good(t *testing.T) {
s := NewStore()
machines := []Machine{{Name: "noc", Host: "77.42.42.205"}, {Name: "de1", Host: "116.202.82.115"}}
s.SetMachines(machines)
ov := s.Overview()
if len(ov.Machines) != 2 {
t.Fatalf("expected 2 machines, got %d", len(ov.Machines))
}
if ov.Machines[0].Name != "noc" {
t.Fatalf("expected noc, got %s", ov.Machines[0].Name)
}
}
func TestOverview_Good_ContainersMergedIntoFirstMachine(t *testing.T) {
s := NewStore()
s.SetMachines([]Machine{{Name: "primary"}, {Name: "secondary"}})
s.SetContainers([]Container{{Name: "forgejo", Status: "running"}})
ov := s.Overview()
if len(ov.Machines[0].Containers) != 1 {
t.Fatal("containers not merged into first machine")
}
if ov.Machines[0].Containers[0].Name != "forgejo" {
t.Fatalf("unexpected container name: %s", ov.Machines[0].Containers[0].Name)
}
if len(ov.Machines[1].Containers) != 0 {
t.Fatal("containers leaked into second machine")
}
}
func TestOverview_Good_EmptyMachinesNoContainerPanic(t *testing.T) {
s := NewStore()
s.SetContainers([]Container{{Name: "c1"}})
// No machines set — should not panic.
ov := s.Overview()
if len(ov.Machines) != 0 {
t.Fatal("expected zero machines")
}
}
func TestOverview_Good_ErrorsCopied(t *testing.T) {
s := NewStore()
s.SetError("prometheus", errors.New("connection refused"))
ov := s.Overview()
if ov.Errors["prometheus"] != "connection refused" {
t.Fatal("error not in overview")
}
// Mutating the copy should not affect the store.
ov.Errors["prometheus"] = "hacked"
ov2 := s.Overview()
if ov2.Errors["prometheus"] != "connection refused" {
t.Fatal("overview errors map is not a copy")
}
}
// ── SetAgents / GetAgents ──────────────────────────────────────────
func TestAgents_Good(t *testing.T) {
s := NewStore()
s.SetAgents(AgentSummary{Available: true, RegisteredTotal: 3, QueuePending: 1})
got := s.GetAgents()
if !got.Available {
t.Fatal("expected Available=true")
}
if got.RegisteredTotal != 3 {
t.Fatalf("expected 3, got %d", got.RegisteredTotal)
}
}
// ── SetTraining / GetTraining ──────────────────────────────────────
func TestTraining_Good(t *testing.T) {
s := NewStore()
s.SetTraining(TrainingSummary{GoldGenerated: 404, GoldTarget: 15000, GoldPercent: 2.69})
got := s.GetTraining()
if got.GoldGenerated != 404 {
t.Fatalf("expected 404, got %d", got.GoldGenerated)
}
}
// ── SetModels / GetModels ──────────────────────────────────────────
func TestModels_Good(t *testing.T) {
s := NewStore()
s.SetModels([]HFModel{{ModelID: "lthn/lem-gemma3-1b", Downloads: 42}})
got := s.GetModels()
if len(got) != 1 {
t.Fatal("expected 1 model")
}
if got[0].Downloads != 42 {
t.Fatalf("expected 42 downloads, got %d", got[0].Downloads)
}
}
// ── SetCommits ─────────────────────────────────────────────────────
func TestCommits_Good(t *testing.T) {
s := NewStore()
s.SetCommits([]Commit{{SHA: "abc123", Message: "feat: test coverage", Author: "virgil"}})
ov := s.Overview()
if len(ov.Commits) != 1 {
t.Fatal("expected 1 commit")
}
if ov.Commits[0].Author != "virgil" {
t.Fatalf("expected virgil, got %s", ov.Commits[0].Author)
}
}
// ── SetContainers / GetContainers ──────────────────────────────────
func TestContainers_Good(t *testing.T) {
s := NewStore()
s.SetContainers([]Container{{Name: "traefik", Status: "running"}, {Name: "forgejo", Status: "running"}})
got := s.GetContainers()
if len(got) != 2 {
t.Fatal("expected 2 containers")
}
}
// ── SetError / GetErrors ───────────────────────────────────────────
func TestSetError_Good_SetAndClear(t *testing.T) {
s := NewStore()
s.SetError("hf", errors.New("rate limited"))
errs := s.GetErrors()
if errs["hf"] != "rate limited" {
t.Fatal("error not stored")
}
// Clear by passing nil.
s.SetError("hf", nil)
errs = s.GetErrors()
if _, ok := errs["hf"]; ok {
t.Fatal("error not cleared")
}
}
func TestGetErrors_Good_ReturnsCopy(t *testing.T) {
s := NewStore()
s.SetError("forge", errors.New("timeout"))
errs := s.GetErrors()
errs["forge"] = "tampered"
fresh := s.GetErrors()
if fresh["forge"] != "timeout" {
t.Fatal("GetErrors did not return a copy")
}
}
// ── SetServices / GetServices ──────────────────────────────────────
func TestServices_Good(t *testing.T) {
s := NewStore()
s.SetServices([]Service{{Name: "Forgejo", URL: "https://forge.lthn.ai", Status: "ok"}})
got := s.GetServices()
if len(got) != 1 {
t.Fatal("expected 1 service")
}
if got[0].Name != "Forgejo" {
t.Fatalf("expected Forgejo, got %s", got[0].Name)
}
}
// ── SetBenchmarks / GetBenchmarks ──────────────────────────────────
func TestBenchmarks_Good(t *testing.T) {
s := NewStore()
s.SetBenchmarks(BenchmarkData{
Runs: []BenchmarkRun{{RunID: "run-1", Model: "gemma3-4b", Type: "training"}},
})
got := s.GetBenchmarks()
if len(got.Runs) != 1 {
t.Fatal("expected 1 benchmark run")
}
}
// ── SetGoldenSet / GetGoldenSet ────────────────────────────────────
func TestGoldenSet_Good(t *testing.T) {
s := NewStore()
s.SetGoldenSet(GoldenSetSummary{Available: true, TotalExamples: 15000, TargetTotal: 15000, CompletionPct: 100})
got := s.GetGoldenSet()
if !got.Available {
t.Fatal("expected Available=true")
}
if got.TotalExamples != 15000 {
t.Fatalf("expected 15000, got %d", got.TotalExamples)
}
}
// ── SetTrainingRuns / GetTrainingRuns ───────────────────────────────
func TestTrainingRuns_Good(t *testing.T) {
s := NewStore()
s.SetTrainingRuns([]TrainingRunStatus{
{Model: "gemma3-4b", RunID: "r1", Status: "training", Iteration: 100, TotalIters: 300},
})
got := s.GetTrainingRuns()
if len(got) != 1 {
t.Fatal("expected 1 training run")
}
if got[0].Iteration != 100 {
t.Fatalf("expected iter 100, got %d", got[0].Iteration)
}
}
// ── SetDataset / GetDataset ────────────────────────────────────────
func TestDataset_Good(t *testing.T) {
s := NewStore()
s.SetDataset(DatasetSummary{
Available: true,
Tables: []DatasetTable{{Name: "golden_set", Rows: 15000}},
})
got := s.GetDataset()
if !got.Available {
t.Fatal("expected Available=true")
}
if len(got.Tables) != 1 {
t.Fatal("expected 1 table")
}
}
// ── Concurrent access (race detector) ──────────────────────────────
func TestConcurrentAccess_Good(t *testing.T) {
s := NewStore()
done := make(chan struct{})
// Writer goroutine.
go func() {
for i := range 100 {
s.SetMachines([]Machine{{Name: "noc"}})
s.SetAgents(AgentSummary{Available: true})
s.SetTraining(TrainingSummary{GoldGenerated: i})
s.SetModels([]HFModel{{ModelID: "m1"}})
s.SetCommits([]Commit{{SHA: "abc"}})
s.SetContainers([]Container{{Name: "c1"}})
s.SetError("test", errors.New("e"))
s.SetServices([]Service{{Name: "s1"}})
s.SetBenchmarks(BenchmarkData{})
s.SetGoldenSet(GoldenSetSummary{})
s.SetTrainingRuns([]TrainingRunStatus{})
s.SetDataset(DatasetSummary{})
}
close(done)
}()
// Reader goroutine.
for range 100 {
_ = s.Overview()
_ = s.GetModels()
_ = s.GetTraining()
_ = s.GetAgents()
_ = s.GetContainers()
_ = s.GetServices()
_ = s.GetBenchmarks()
_ = s.GetGoldenSet()
_ = s.GetTrainingRuns()
_ = s.GetDataset()
_ = s.GetErrors()
}
<-done
}

View file

@ -1,11 +1,110 @@
package plugin
import (
"context"
"testing"
"forge.lthn.ai/core/go/pkg/io"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ── NewInstaller ───────────────────────────────────────────────────
func TestNewInstaller_Good(t *testing.T) {
m := io.NewMockMedium()
reg := NewRegistry(m, "/plugins")
inst := NewInstaller(m, reg)
assert.NotNil(t, inst)
assert.Equal(t, m, inst.medium)
assert.Equal(t, reg, inst.registry)
}
// ── Install error paths ────────────────────────────────────────────
func TestInstall_Bad_InvalidSource(t *testing.T) {
m := io.NewMockMedium()
reg := NewRegistry(m, "/plugins")
inst := NewInstaller(m, reg)
err := inst.Install(context.Background(), "bad-source")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid source")
}
func TestInstall_Bad_AlreadyInstalled(t *testing.T) {
m := io.NewMockMedium()
reg := NewRegistry(m, "/plugins")
_ = reg.Add(&PluginConfig{Name: "my-plugin", Version: "1.0.0"})
inst := NewInstaller(m, reg)
err := inst.Install(context.Background(), "org/my-plugin")
assert.Error(t, err)
assert.Contains(t, err.Error(), "already installed")
}
// ── Remove ─────────────────────────────────────────────────────────
func TestRemove_Good(t *testing.T) {
m := io.NewMockMedium()
reg := NewRegistry(m, "/plugins")
_ = reg.Add(&PluginConfig{Name: "removable", Version: "1.0.0"})
// Create plugin directory.
_ = m.EnsureDir("/plugins/removable")
_ = m.Write("/plugins/removable/plugin.json", `{"name":"removable"}`)
inst := NewInstaller(m, reg)
err := inst.Remove("removable")
require.NoError(t, err)
// Plugin removed from registry.
_, ok := reg.Get("removable")
assert.False(t, ok)
// Directory cleaned up.
assert.False(t, m.Exists("/plugins/removable"))
}
func TestRemove_Good_DirAlreadyGone(t *testing.T) {
m := io.NewMockMedium()
reg := NewRegistry(m, "/plugins")
_ = reg.Add(&PluginConfig{Name: "ghost", Version: "1.0.0"})
// No directory exists — should still succeed.
inst := NewInstaller(m, reg)
err := inst.Remove("ghost")
require.NoError(t, err)
_, ok := reg.Get("ghost")
assert.False(t, ok)
}
func TestRemove_Bad_NotFound(t *testing.T) {
m := io.NewMockMedium()
reg := NewRegistry(m, "/plugins")
inst := NewInstaller(m, reg)
err := inst.Remove("nonexistent")
assert.Error(t, err)
assert.Contains(t, err.Error(), "plugin not found")
}
// ── Update error paths ─────────────────────────────────────────────
func TestUpdate_Bad_NotFound(t *testing.T) {
m := io.NewMockMedium()
reg := NewRegistry(m, "/plugins")
inst := NewInstaller(m, reg)
err := inst.Update(context.Background(), "missing")
assert.Error(t, err)
assert.Contains(t, err.Error(), "plugin not found")
}
// ── ParseSource ────────────────────────────────────────────────────
func TestParseSource_Good_OrgRepo(t *testing.T) {
org, repo, version, err := ParseSource("host-uk/core-plugin")
assert.NoError(t, err)

View file

@ -134,3 +134,59 @@ func TestRegistry_Load_Good_EmptyWhenNoFile(t *testing.T) {
assert.NoError(t, err)
assert.Empty(t, reg.List())
}
func TestRegistry_Load_Bad_InvalidJSON(t *testing.T) {
m := io.NewMockMedium()
basePath := "/home/user/.core/plugins"
_ = m.Write(basePath+"/registry.json", "not valid json {{{")
reg := NewRegistry(m, basePath)
err := reg.Load()
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse registry")
}
func TestRegistry_Load_Good_NullJSON(t *testing.T) {
m := io.NewMockMedium()
basePath := "/home/user/.core/plugins"
_ = m.Write(basePath+"/registry.json", "null")
reg := NewRegistry(m, basePath)
err := reg.Load()
assert.NoError(t, err)
assert.Empty(t, reg.List())
}
func TestRegistry_Save_Good_CreatesDir(t *testing.T) {
m := io.NewMockMedium()
basePath := "/home/user/.core/plugins"
reg := NewRegistry(m, basePath)
_ = reg.Add(&PluginConfig{Name: "test", Version: "1.0.0"})
err := reg.Save()
assert.NoError(t, err)
// Verify file was written.
assert.True(t, m.IsFile(basePath+"/registry.json"))
}
func TestRegistry_List_Good_Sorted(t *testing.T) {
m := io.NewMockMedium()
reg := NewRegistry(m, "/plugins")
_ = reg.Add(&PluginConfig{Name: "zebra", Version: "1.0.0"})
_ = reg.Add(&PluginConfig{Name: "alpha", Version: "1.0.0"})
_ = reg.Add(&PluginConfig{Name: "middle", Version: "1.0.0"})
list := reg.List()
assert.Len(t, list, 3)
assert.Equal(t, "alpha", list[0].Name)
assert.Equal(t, "middle", list[1].Name)
assert.Equal(t, "zebra", list[2].Name)
}
func TestRegistry_RegistryPath_Good(t *testing.T) {
m := io.NewMockMedium()
reg := NewRegistry(m, "/base/path")
assert.Equal(t, "/base/path/registry.json", reg.registryPath())
}

View file

@ -5,9 +5,12 @@ import (
"forge.lthn.ai/core/go/pkg/io"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoadRegistry(t *testing.T) {
// ── LoadRegistry ───────────────────────────────────────────────────
func TestLoadRegistry_Good(t *testing.T) {
m := io.NewMockMedium()
yaml := `
version: 1
@ -34,7 +37,401 @@ repos:
assert.Equal(t, reg, repo.registry)
}
func TestRepo_Exists(t *testing.T) {
func TestLoadRegistry_Good_WithDefaults(t *testing.T) {
m := io.NewMockMedium()
yaml := `
version: 1
org: host-uk
base_path: /tmp/repos
defaults:
ci: github-actions
license: EUPL-1.2
branch: main
repos:
core-php:
type: foundation
description: Foundation
core-admin:
type: module
description: Admin panel
`
_ = m.Write("/tmp/repos.yaml", yaml)
reg, err := LoadRegistry(m, "/tmp/repos.yaml")
require.NoError(t, err)
php, ok := reg.Get("core-php")
require.True(t, ok)
assert.Equal(t, "github-actions", php.CI)
admin, ok := reg.Get("core-admin")
require.True(t, ok)
assert.Equal(t, "github-actions", admin.CI)
}
func TestLoadRegistry_Good_CustomRepoPath(t *testing.T) {
m := io.NewMockMedium()
yaml := `
version: 1
org: host-uk
base_path: /tmp/repos
repos:
special:
type: module
path: /opt/special-repo
`
_ = m.Write("/tmp/repos.yaml", yaml)
reg, err := LoadRegistry(m, "/tmp/repos.yaml")
require.NoError(t, err)
repo, ok := reg.Get("special")
require.True(t, ok)
assert.Equal(t, "/opt/special-repo", repo.Path)
}
func TestLoadRegistry_Good_CIOverride(t *testing.T) {
m := io.NewMockMedium()
yaml := `
version: 1
org: test
base_path: /tmp
defaults:
ci: default-ci
repos:
a:
type: module
b:
type: module
ci: custom-ci
`
_ = m.Write("/tmp/repos.yaml", yaml)
reg, err := LoadRegistry(m, "/tmp/repos.yaml")
require.NoError(t, err)
a, _ := reg.Get("a")
assert.Equal(t, "default-ci", a.CI)
b, _ := reg.Get("b")
assert.Equal(t, "custom-ci", b.CI)
}
func TestLoadRegistry_Bad_FileNotFound(t *testing.T) {
m := io.NewMockMedium()
_, err := LoadRegistry(m, "/nonexistent/repos.yaml")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to read")
}
func TestLoadRegistry_Bad_InvalidYAML(t *testing.T) {
m := io.NewMockMedium()
_ = m.Write("/tmp/bad.yaml", "{{{{not yaml at all")
_, err := LoadRegistry(m, "/tmp/bad.yaml")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse")
}
// ── List / Get / ByType ────────────────────────────────────────────
func newTestRegistry(t *testing.T) *Registry {
t.Helper()
m := io.NewMockMedium()
yaml := `
version: 1
org: host-uk
base_path: /tmp/repos
repos:
core-php:
type: foundation
description: Foundation
core-admin:
type: module
depends_on: [core-php]
description: Admin
core-tenant:
type: module
depends_on: [core-php]
description: Tenancy
core-bio:
type: product
depends_on: [core-php, core-tenant]
description: Bio product
`
_ = m.Write("/tmp/repos.yaml", yaml)
reg, err := LoadRegistry(m, "/tmp/repos.yaml")
require.NoError(t, err)
return reg
}
func TestRegistry_List_Good(t *testing.T) {
reg := newTestRegistry(t)
repos := reg.List()
assert.Len(t, repos, 4)
}
func TestRegistry_Get_Good(t *testing.T) {
reg := newTestRegistry(t)
repo, ok := reg.Get("core-php")
assert.True(t, ok)
assert.Equal(t, "core-php", repo.Name)
}
func TestRegistry_Get_Bad_NotFound(t *testing.T) {
reg := newTestRegistry(t)
_, ok := reg.Get("nonexistent")
assert.False(t, ok)
}
func TestRegistry_ByType_Good(t *testing.T) {
reg := newTestRegistry(t)
foundations := reg.ByType("foundation")
assert.Len(t, foundations, 1)
assert.Equal(t, "core-php", foundations[0].Name)
modules := reg.ByType("module")
assert.Len(t, modules, 2)
products := reg.ByType("product")
assert.Len(t, products, 1)
}
func TestRegistry_ByType_Good_NoMatch(t *testing.T) {
reg := newTestRegistry(t)
templates := reg.ByType("template")
assert.Empty(t, templates)
}
// ── TopologicalOrder ───────────────────────────────────────────────
func TestTopologicalOrder_Good(t *testing.T) {
reg := newTestRegistry(t)
order, err := TopologicalOrder(reg)
require.NoError(t, err)
assert.Len(t, order, 4)
// core-php must come before everything that depends on it.
phpIdx := -1
for i, r := range order {
if r.Name == "core-php" {
phpIdx = i
break
}
}
require.GreaterOrEqual(t, phpIdx, 0, "core-php not found")
for i, r := range order {
for _, dep := range r.DependsOn {
depIdx := -1
for j, d := range order {
if d.Name == dep {
depIdx = j
break
}
}
assert.Less(t, depIdx, i, "%s should come before %s", dep, r.Name)
}
}
}
func TopologicalOrder(reg *Registry) ([]*Repo, error) {
return reg.TopologicalOrder()
}
func TestTopologicalOrder_Bad_CircularDep(t *testing.T) {
m := io.NewMockMedium()
yaml := `
version: 1
org: test
base_path: /tmp
repos:
a:
type: module
depends_on: [b]
b:
type: module
depends_on: [a]
`
_ = m.Write("/tmp/repos.yaml", yaml)
reg, err := LoadRegistry(m, "/tmp/repos.yaml")
require.NoError(t, err)
_, err = reg.TopologicalOrder()
assert.Error(t, err)
assert.Contains(t, err.Error(), "circular dependency")
}
func TestTopologicalOrder_Bad_UnknownDep(t *testing.T) {
m := io.NewMockMedium()
yaml := `
version: 1
org: test
base_path: /tmp
repos:
a:
type: module
depends_on: [nonexistent]
`
_ = m.Write("/tmp/repos.yaml", yaml)
reg, err := LoadRegistry(m, "/tmp/repos.yaml")
require.NoError(t, err)
_, err = reg.TopologicalOrder()
assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown repo")
}
func TestTopologicalOrder_Good_NoDeps(t *testing.T) {
m := io.NewMockMedium()
yaml := `
version: 1
org: test
base_path: /tmp
repos:
a:
type: module
b:
type: module
`
_ = m.Write("/tmp/repos.yaml", yaml)
reg, err := LoadRegistry(m, "/tmp/repos.yaml")
require.NoError(t, err)
order, err := reg.TopologicalOrder()
require.NoError(t, err)
assert.Len(t, order, 2)
}
// ── ScanDirectory ──────────────────────────────────────────────────
func TestScanDirectory_Good(t *testing.T) {
m := io.NewMockMedium()
// Create mock repos with .git dirs.
_ = m.EnsureDir("/workspace/repo-a/.git")
_ = m.EnsureDir("/workspace/repo-b/.git")
_ = m.EnsureDir("/workspace/not-a-repo") // No .git
// Write a file (not a dir) at top level.
_ = m.Write("/workspace/README.md", "hello")
reg, err := ScanDirectory(m, "/workspace")
require.NoError(t, err)
assert.Len(t, reg.Repos, 2)
a, ok := reg.Repos["repo-a"]
assert.True(t, ok)
assert.Equal(t, "/workspace/repo-a", a.Path)
assert.Equal(t, "module", a.Type) // Default type.
_, ok = reg.Repos["not-a-repo"]
assert.False(t, ok)
}
func TestScanDirectory_Good_DetectsGitHubOrg(t *testing.T) {
m := io.NewMockMedium()
_ = m.EnsureDir("/workspace/my-repo/.git")
_ = m.Write("/workspace/my-repo/.git/config", `[core]
repositoryformatversion = 0
[remote "origin"]
url = git@github.com:host-uk/my-repo.git
fetch = +refs/heads/*:refs/remotes/origin/*
`)
reg, err := ScanDirectory(m, "/workspace")
require.NoError(t, err)
assert.Equal(t, "host-uk", reg.Org)
}
func TestScanDirectory_Good_DetectsHTTPSOrg(t *testing.T) {
m := io.NewMockMedium()
_ = m.EnsureDir("/workspace/my-repo/.git")
_ = m.Write("/workspace/my-repo/.git/config", `[remote "origin"]
url = https://github.com/lethean-io/my-repo.git
`)
reg, err := ScanDirectory(m, "/workspace")
require.NoError(t, err)
assert.Equal(t, "lethean-io", reg.Org)
}
func TestScanDirectory_Good_EmptyDir(t *testing.T) {
m := io.NewMockMedium()
_ = m.EnsureDir("/empty")
reg, err := ScanDirectory(m, "/empty")
require.NoError(t, err)
assert.Empty(t, reg.Repos)
assert.Equal(t, "", reg.Org)
}
func TestScanDirectory_Bad_InvalidDir(t *testing.T) {
m := io.NewMockMedium()
_, err := ScanDirectory(m, "/nonexistent")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to read directory")
}
// ── detectOrg ──────────────────────────────────────────────────────
func TestDetectOrg_Good_SSHRemote(t *testing.T) {
m := io.NewMockMedium()
_ = m.Write("/repo/.git/config", `[remote "origin"]
url = git@github.com:host-uk/core.git
`)
assert.Equal(t, "host-uk", detectOrg(m, "/repo"))
}
func TestDetectOrg_Good_HTTPSRemote(t *testing.T) {
m := io.NewMockMedium()
_ = m.Write("/repo/.git/config", `[remote "origin"]
url = https://github.com/snider/project.git
`)
assert.Equal(t, "snider", detectOrg(m, "/repo"))
}
func TestDetectOrg_Bad_NoConfig(t *testing.T) {
m := io.NewMockMedium()
assert.Equal(t, "", detectOrg(m, "/nonexistent"))
}
func TestDetectOrg_Bad_NoRemote(t *testing.T) {
m := io.NewMockMedium()
_ = m.Write("/repo/.git/config", `[core]
repositoryformatversion = 0
`)
assert.Equal(t, "", detectOrg(m, "/repo"))
}
func TestDetectOrg_Bad_NonGitHubRemote(t *testing.T) {
m := io.NewMockMedium()
_ = m.Write("/repo/.git/config", `[remote "origin"]
url = ssh://git@forge.lthn.ai:2223/core/go.git
`)
assert.Equal(t, "", detectOrg(m, "/repo"))
}
// ── expandPath ─────────────────────────────────────────────────────
func TestExpandPath_Good_Tilde(t *testing.T) {
got := expandPath("~/Code/repos")
assert.NotContains(t, got, "~")
assert.Contains(t, got, "Code/repos")
}
func TestExpandPath_Good_NoTilde(t *testing.T) {
assert.Equal(t, "/absolute/path", expandPath("/absolute/path"))
assert.Equal(t, "relative/path", expandPath("relative/path"))
}
// ── Repo.Exists / IsGitRepo ───────────────────────────────────────
func TestRepo_Exists_Good(t *testing.T) {
m := io.NewMockMedium()
reg := &Registry{
medium: m,
@ -47,15 +444,13 @@ func TestRepo_Exists(t *testing.T) {
registry: reg,
}
// Not exists yet
assert.False(t, repo.Exists())
// Create directory in mock
_ = m.EnsureDir("/tmp/repos/core")
assert.True(t, repo.Exists())
}
func TestRepo_IsGitRepo(t *testing.T) {
func TestRepo_IsGitRepo_Good(t *testing.T) {
m := io.NewMockMedium()
reg := &Registry{
medium: m,
@ -68,10 +463,24 @@ func TestRepo_IsGitRepo(t *testing.T) {
registry: reg,
}
// Not a git repo yet
assert.False(t, repo.IsGitRepo())
// Create .git directory in mock
_ = m.EnsureDir("/tmp/repos/core/.git")
assert.True(t, repo.IsGitRepo())
}
// ── getMedium fallback ─────────────────────────────────────────────
func TestGetMedium_Good_FallbackToLocal(t *testing.T) {
repo := &Repo{Name: "orphan", Path: "/tmp/orphan"}
// No registry set — should fall back to io.Local.
m := repo.getMedium()
assert.Equal(t, io.Local, m)
}
func TestGetMedium_Good_NilMediumFallback(t *testing.T) {
reg := &Registry{} // medium is nil.
repo := &Repo{Name: "test", registry: reg}
m := repo.getMedium()
assert.Equal(t, io.Local, m)
}

194
pkg/session/html_test.go Normal file
View file

@ -0,0 +1,194 @@
package session
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestRenderHTML_Good_BasicSession(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "session.html")
sess := &Session{
ID: "f3fb074c-8c72-4da6-a15a-85bae652ccaa",
StartTime: time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 2, 24, 10, 5, 0, 0, time.UTC),
Events: []Event{
{
Timestamp: time.Date(2026, 2, 24, 10, 0, 5, 0, time.UTC),
Type: "tool_use",
Tool: "Bash",
Input: "go test ./...",
Output: "ok forge.lthn.ai/core/go 1.2s",
Duration: time.Second,
Success: true,
},
{
Timestamp: time.Date(2026, 2, 24, 10, 1, 0, 0, time.UTC),
Type: "tool_use",
Tool: "Read",
Input: "/tmp/test.go",
Output: "package main",
Duration: 200 * time.Millisecond,
Success: true,
},
{
Timestamp: time.Date(2026, 2, 24, 10, 2, 0, 0, time.UTC),
Type: "user",
Input: "looks good",
},
},
}
if err := RenderHTML(sess, out); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
html := string(data)
if !strings.Contains(html, "f3fb074c") {
t.Fatal("missing session ID")
}
if !strings.Contains(html, "go test ./...") {
t.Fatal("missing bash command")
}
if !strings.Contains(html, "2 tool calls") {
t.Fatal("missing tool count")
}
if !strings.Contains(html, "filterEvents") {
t.Fatal("missing JS filter function")
}
}
func TestRenderHTML_Good_WithErrors(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "errors.html")
sess := &Session{
ID: "err-session",
StartTime: time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 2, 24, 10, 1, 0, 0, time.UTC),
Events: []Event{
{
Type: "tool_use", Tool: "Bash",
Timestamp: time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC),
Input: "bad command", Output: "error", Success: false,
},
},
}
if err := RenderHTML(sess, out); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(out)
html := string(data)
if !strings.Contains(html, "1 errors") {
t.Fatal("missing error count")
}
if !strings.Contains(html, `class="event error"`) {
t.Fatal("missing error class")
}
if !strings.Contains(html, "&#10007;") {
t.Fatal("missing failure icon")
}
}
func TestRenderHTML_Good_AssistantEvent(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "asst.html")
sess := &Session{
ID: "asst-test",
StartTime: time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 2, 24, 10, 0, 5, 0, time.UTC),
Events: []Event{
{
Type: "assistant",
Timestamp: time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC),
Input: "Let me check that.",
},
},
}
if err := RenderHTML(sess, out); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(out)
if !strings.Contains(string(data), "Claude") {
t.Fatal("missing Claude label for assistant")
}
}
func TestRenderHTML_Good_EmptySession(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "empty.html")
sess := &Session{
ID: "empty",
StartTime: time.Now(),
EndTime: time.Now(),
}
if err := RenderHTML(sess, out); err != nil {
t.Fatal(err)
}
info, err := os.Stat(out)
if err != nil {
t.Fatal(err)
}
if info.Size() == 0 {
t.Fatal("HTML file is empty")
}
}
func TestRenderHTML_Bad_InvalidPath(t *testing.T) {
sess := &Session{ID: "test", StartTime: time.Now(), EndTime: time.Now()}
err := RenderHTML(sess, "/nonexistent/dir/out.html")
if err == nil {
t.Fatal("expected error for invalid path")
}
}
func TestRenderHTML_Good_XSSEscaping(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "xss.html")
sess := &Session{
ID: "xss-test",
StartTime: time.Now(),
EndTime: time.Now(),
Events: []Event{
{
Type: "tool_use",
Tool: "Bash",
Timestamp: time.Now(),
Input: `echo "<script>alert('xss')</script>"`,
Output: `<img onerror=alert(1)>`,
Success: true,
},
},
}
if err := RenderHTML(sess, out); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(out)
html := string(data)
if strings.Contains(html, "<script>alert") {
t.Fatal("XSS: unescaped script tag in HTML output")
}
if strings.Contains(html, "<img onerror") {
t.Fatal("XSS: unescaped img tag in HTML output")
}
}

498
pkg/session/parser_test.go Normal file
View file

@ -0,0 +1,498 @@
package session
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
// ── truncate ───────────────────────────────────────────────────────
func TestTruncate_Good_Short(t *testing.T) {
if got := truncate("hello", 10); got != "hello" {
t.Fatalf("expected hello, got %s", got)
}
}
func TestTruncate_Good_Exact(t *testing.T) {
if got := truncate("12345", 5); got != "12345" {
t.Fatalf("expected 12345, got %s", got)
}
}
func TestTruncate_Good_Long(t *testing.T) {
got := truncate("hello world", 5)
if got != "hello..." {
t.Fatalf("expected hello..., got %s", got)
}
}
func TestTruncate_Good_Empty(t *testing.T) {
if got := truncate("", 10); got != "" {
t.Fatalf("expected empty, got %s", got)
}
}
// ── shortID ────────────────────────────────────────────────────────
func TestShortID_Good_Long(t *testing.T) {
got := shortID("f3fb074c-8c72-4da6-a15a-85bae652ccaa")
if got != "f3fb074c" {
t.Fatalf("expected f3fb074c, got %s", got)
}
}
func TestShortID_Good_Short(t *testing.T) {
if got := shortID("abc"); got != "abc" {
t.Fatalf("expected abc, got %s", got)
}
}
func TestShortID_Good_ExactEight(t *testing.T) {
if got := shortID("12345678"); got != "12345678" {
t.Fatalf("expected 12345678, got %s", got)
}
}
// ── formatDuration ─────────────────────────────────────────────────
func TestFormatDuration_Good_Milliseconds(t *testing.T) {
got := formatDuration(500 * time.Millisecond)
if got != "500ms" {
t.Fatalf("expected 500ms, got %s", got)
}
}
func TestFormatDuration_Good_Seconds(t *testing.T) {
got := formatDuration(3500 * time.Millisecond)
if got != "3.5s" {
t.Fatalf("expected 3.5s, got %s", got)
}
}
func TestFormatDuration_Good_Minutes(t *testing.T) {
got := formatDuration(2*time.Minute + 30*time.Second)
if got != "2m30s" {
t.Fatalf("expected 2m30s, got %s", got)
}
}
func TestFormatDuration_Good_Hours(t *testing.T) {
got := formatDuration(1*time.Hour + 15*time.Minute)
if got != "1h15m" {
t.Fatalf("expected 1h15m, got %s", got)
}
}
// ── extractToolInput ───────────────────────────────────────────────
func TestExtractToolInput_Good_Bash(t *testing.T) {
raw := json.RawMessage(`{"command":"go test ./...","description":"run tests"}`)
got := extractToolInput("Bash", raw)
if got != "go test ./... # run tests" {
t.Fatalf("unexpected: %s", got)
}
}
func TestExtractToolInput_Good_BashNoDesc(t *testing.T) {
raw := json.RawMessage(`{"command":"ls"}`)
got := extractToolInput("Bash", raw)
if got != "ls" {
t.Fatalf("expected ls, got %s", got)
}
}
func TestExtractToolInput_Good_Read(t *testing.T) {
raw := json.RawMessage(`{"file_path":"/tmp/test.go"}`)
got := extractToolInput("Read", raw)
if got != "/tmp/test.go" {
t.Fatalf("expected /tmp/test.go, got %s", got)
}
}
func TestExtractToolInput_Good_Edit(t *testing.T) {
raw := json.RawMessage(`{"file_path":"/tmp/test.go","old_string":"foo","new_string":"bar"}`)
got := extractToolInput("Edit", raw)
if got != "/tmp/test.go (edit)" {
t.Fatalf("expected /tmp/test.go (edit), got %s", got)
}
}
func TestExtractToolInput_Good_Write(t *testing.T) {
raw := json.RawMessage(`{"file_path":"/tmp/out.go","content":"package main"}`)
got := extractToolInput("Write", raw)
if got != "/tmp/out.go (12 bytes)" {
t.Fatalf("unexpected: %s", got)
}
}
func TestExtractToolInput_Good_Grep(t *testing.T) {
raw := json.RawMessage(`{"pattern":"TODO","path":"/src"}`)
got := extractToolInput("Grep", raw)
if got != "/TODO/ in /src" {
t.Fatalf("unexpected: %s", got)
}
}
func TestExtractToolInput_Good_GrepNoPath(t *testing.T) {
raw := json.RawMessage(`{"pattern":"TODO"}`)
got := extractToolInput("Grep", raw)
if got != "/TODO/ in ." {
t.Fatalf("unexpected: %s", got)
}
}
func TestExtractToolInput_Good_Glob(t *testing.T) {
raw := json.RawMessage(`{"pattern":"**/*.go"}`)
got := extractToolInput("Glob", raw)
if got != "**/*.go" {
t.Fatalf("unexpected: %s", got)
}
}
func TestExtractToolInput_Good_Task(t *testing.T) {
raw := json.RawMessage(`{"prompt":"investigate the bug","description":"debug helper","subagent_type":"Explore"}`)
got := extractToolInput("Task", raw)
if got != "[Explore] debug helper" {
t.Fatalf("unexpected: %s", got)
}
}
func TestExtractToolInput_Good_TaskNoDesc(t *testing.T) {
raw := json.RawMessage(`{"prompt":"investigate the bug","subagent_type":"Explore"}`)
got := extractToolInput("Task", raw)
if got != "[Explore] investigate the bug" {
t.Fatalf("unexpected: %s", got)
}
}
func TestExtractToolInput_Good_UnknownTool(t *testing.T) {
raw := json.RawMessage(`{"alpha":"1","beta":"2"}`)
got := extractToolInput("CustomTool", raw)
if got != "alpha, beta" {
t.Fatalf("unexpected: %s", got)
}
}
func TestExtractToolInput_Good_NilInput(t *testing.T) {
got := extractToolInput("Bash", nil)
if got != "" {
t.Fatalf("expected empty, got %s", got)
}
}
func TestExtractToolInput_Bad_InvalidJSON(t *testing.T) {
raw := json.RawMessage(`not json`)
got := extractToolInput("Bash", raw)
// Falls through to fallback, which also fails — returns empty.
if got != "" {
t.Fatalf("expected empty, got %s", got)
}
}
// ── extractResultContent ───────────────────────────────────────────
func TestExtractResultContent_Good_String(t *testing.T) {
got := extractResultContent("hello")
if got != "hello" {
t.Fatalf("expected hello, got %s", got)
}
}
func TestExtractResultContent_Good_Slice(t *testing.T) {
input := []any{
map[string]any{"text": "line1"},
map[string]any{"text": "line2"},
}
got := extractResultContent(input)
if got != "line1\nline2" {
t.Fatalf("unexpected: %s", got)
}
}
func TestExtractResultContent_Good_Map(t *testing.T) {
input := map[string]any{"text": "content"}
got := extractResultContent(input)
if got != "content" {
t.Fatalf("expected content, got %s", got)
}
}
func TestExtractResultContent_Good_MapNoText(t *testing.T) {
input := map[string]any{"data": 42}
got := extractResultContent(input)
if got == "" {
t.Fatal("expected non-empty fallback")
}
}
func TestExtractResultContent_Good_Other(t *testing.T) {
got := extractResultContent(42)
if got != "42" {
t.Fatalf("expected 42, got %s", got)
}
}
// ── ParseTranscript ────────────────────────────────────────────────
func writeJSONL(t *testing.T, path string, entries []any) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
enc := json.NewEncoder(f)
for _, e := range entries {
if err := enc.Encode(e); err != nil {
t.Fatal(err)
}
}
}
func TestParseTranscript_Good_BasicFlow(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test-session.jsonl")
ts1 := time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC)
ts2 := time.Date(2026, 2, 24, 10, 0, 1, 0, time.UTC)
ts3 := time.Date(2026, 2, 24, 10, 0, 2, 0, time.UTC)
entries := []any{
map[string]any{
"type": "assistant", "timestamp": ts1.Format(time.RFC3339Nano),
"message": map[string]any{
"role": "assistant",
"content": []any{
map[string]any{
"type": "tool_use", "id": "tu_1", "name": "Bash",
"input": map[string]any{"command": "go test ./...", "description": "run tests"},
},
},
},
},
map[string]any{
"type": "user", "timestamp": ts2.Format(time.RFC3339Nano),
"message": map[string]any{
"role": "user",
"content": []any{
map[string]any{
"type": "tool_result", "tool_use_id": "tu_1",
"content": "ok forge.lthn.ai/core/go 1.2s",
},
},
},
},
map[string]any{
"type": "user", "timestamp": ts3.Format(time.RFC3339Nano),
"message": map[string]any{
"role": "user",
"content": []any{
map[string]any{
"type": "text", "text": "nice work",
},
},
},
},
}
writeJSONL(t, path, entries)
sess, err := ParseTranscript(path)
if err != nil {
t.Fatal(err)
}
if sess.ID != "test-session" {
t.Fatalf("expected test-session, got %s", sess.ID)
}
if len(sess.Events) != 2 {
t.Fatalf("expected 2 events, got %d", len(sess.Events))
}
// Tool use event.
tool := sess.Events[0]
if tool.Type != "tool_use" {
t.Fatalf("expected tool_use, got %s", tool.Type)
}
if tool.Tool != "Bash" {
t.Fatalf("expected Bash, got %s", tool.Tool)
}
if !tool.Success {
t.Fatal("expected success")
}
if tool.Duration != time.Second {
t.Fatalf("expected 1s duration, got %s", tool.Duration)
}
// User message.
user := sess.Events[1]
if user.Type != "user" {
t.Fatalf("expected user, got %s", user.Type)
}
if user.Input != "nice work" {
t.Fatalf("unexpected input: %s", user.Input)
}
}
func TestParseTranscript_Good_ToolError(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "err-session.jsonl")
ts1 := time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC)
ts2 := time.Date(2026, 2, 24, 10, 0, 1, 0, time.UTC)
isError := true
entries := []any{
map[string]any{
"type": "assistant", "timestamp": ts1.Format(time.RFC3339Nano),
"message": map[string]any{
"role": "assistant",
"content": []any{
map[string]any{
"type": "tool_use", "id": "tu_err", "name": "Bash",
"input": map[string]any{"command": "rm -rf /"},
},
},
},
},
map[string]any{
"type": "user", "timestamp": ts2.Format(time.RFC3339Nano),
"message": map[string]any{
"role": "user",
"content": []any{
map[string]any{
"type": "tool_result", "tool_use_id": "tu_err",
"content": "permission denied", "is_error": &isError,
},
},
},
},
}
writeJSONL(t, path, entries)
sess, err := ParseTranscript(path)
if err != nil {
t.Fatal(err)
}
if len(sess.Events) != 1 {
t.Fatalf("expected 1 event, got %d", len(sess.Events))
}
if sess.Events[0].Success {
t.Fatal("expected failure")
}
if sess.Events[0].ErrorMsg != "permission denied" {
t.Fatalf("unexpected error: %s", sess.Events[0].ErrorMsg)
}
}
func TestParseTranscript_Good_AssistantText(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "asst.jsonl")
ts := time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC)
entries := []any{
map[string]any{
"type": "assistant", "timestamp": ts.Format(time.RFC3339Nano),
"message": map[string]any{
"role": "assistant",
"content": []any{
map[string]any{"type": "text", "text": "Let me check that."},
},
},
},
}
writeJSONL(t, path, entries)
sess, err := ParseTranscript(path)
if err != nil {
t.Fatal(err)
}
if len(sess.Events) != 1 {
t.Fatalf("expected 1 event, got %d", len(sess.Events))
}
if sess.Events[0].Type != "assistant" {
t.Fatalf("expected assistant, got %s", sess.Events[0].Type)
}
}
func TestParseTranscript_Bad_MissingFile(t *testing.T) {
_, err := ParseTranscript("/nonexistent/path.jsonl")
if err == nil {
t.Fatal("expected error for missing file")
}
}
func TestParseTranscript_Good_EmptyFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "empty.jsonl")
os.WriteFile(path, []byte{}, 0644)
sess, err := ParseTranscript(path)
if err != nil {
t.Fatal(err)
}
if len(sess.Events) != 0 {
t.Fatalf("expected 0 events, got %d", len(sess.Events))
}
}
func TestParseTranscript_Good_MalformedLines(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.jsonl")
os.WriteFile(path, []byte("not json\n{also bad\n"), 0644)
sess, err := ParseTranscript(path)
if err != nil {
t.Fatal(err)
}
if len(sess.Events) != 0 {
t.Fatalf("expected 0 events from bad lines, got %d", len(sess.Events))
}
}
// ── ListSessions ───────────────────────────────────────────────────
func TestListSessions_Good(t *testing.T) {
dir := t.TempDir()
ts1 := time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC)
ts2 := time.Date(2026, 2, 24, 11, 0, 0, 0, time.UTC)
writeJSONL(t, filepath.Join(dir, "sess-a.jsonl"), []any{
map[string]any{"type": "assistant", "timestamp": ts1.Format(time.RFC3339Nano),
"message": map[string]any{"role": "assistant", "content": []any{}}},
})
writeJSONL(t, filepath.Join(dir, "sess-b.jsonl"), []any{
map[string]any{"type": "assistant", "timestamp": ts2.Format(time.RFC3339Nano),
"message": map[string]any{"role": "assistant", "content": []any{}}},
})
sessions, err := ListSessions(dir)
if err != nil {
t.Fatal(err)
}
if len(sessions) != 2 {
t.Fatalf("expected 2 sessions, got %d", len(sessions))
}
// Sorted newest first.
if sessions[0].ID != "sess-b" {
t.Fatalf("expected sess-b first, got %s", sessions[0].ID)
}
}
func TestListSessions_Good_EmptyDir(t *testing.T) {
dir := t.TempDir()
sessions, err := ListSessions(dir)
if err != nil {
t.Fatal(err)
}
if len(sessions) != 0 {
t.Fatalf("expected 0, got %d", len(sessions))
}
}

172
pkg/session/search_test.go Normal file
View file

@ -0,0 +1,172 @@
package session
import (
"path/filepath"
"testing"
"time"
)
func TestSearch_Good_MatchFound(t *testing.T) {
dir := t.TempDir()
ts1 := time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC)
ts2 := time.Date(2026, 2, 24, 10, 0, 1, 0, time.UTC)
writeJSONL(t, filepath.Join(dir, "search-test.jsonl"), []any{
map[string]any{
"type": "assistant", "timestamp": ts1.Format(time.RFC3339Nano),
"message": map[string]any{
"role": "assistant",
"content": []any{
map[string]any{
"type": "tool_use", "id": "tu_1", "name": "Bash",
"input": map[string]any{"command": "go test ./..."},
},
},
},
},
map[string]any{
"type": "user", "timestamp": ts2.Format(time.RFC3339Nano),
"message": map[string]any{
"role": "user",
"content": []any{
map[string]any{
"type": "tool_result", "tool_use_id": "tu_1",
"content": "ok forge.lthn.ai/core/go 1.2s",
},
},
},
},
})
results, err := Search(dir, "go test")
if err != nil {
t.Fatal(err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if results[0].Tool != "Bash" {
t.Fatalf("expected Bash, got %s", results[0].Tool)
}
}
func TestSearch_Good_CaseInsensitive(t *testing.T) {
dir := t.TempDir()
ts1 := time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC)
ts2 := time.Date(2026, 2, 24, 10, 0, 1, 0, time.UTC)
writeJSONL(t, filepath.Join(dir, "case.jsonl"), []any{
map[string]any{
"type": "assistant", "timestamp": ts1.Format(time.RFC3339Nano),
"message": map[string]any{
"role": "assistant",
"content": []any{
map[string]any{
"type": "tool_use", "id": "tu_2", "name": "Bash",
"input": map[string]any{"command": "GO TEST"},
},
},
},
},
map[string]any{
"type": "user", "timestamp": ts2.Format(time.RFC3339Nano),
"message": map[string]any{
"role": "user",
"content": []any{
map[string]any{
"type": "tool_result", "tool_use_id": "tu_2",
"content": "ok",
},
},
},
},
})
results, err := Search(dir, "go test")
if err != nil {
t.Fatal(err)
}
if len(results) != 1 {
t.Fatal("case insensitive search should match")
}
}
func TestSearch_Good_NoMatch(t *testing.T) {
dir := t.TempDir()
ts1 := time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC)
ts2 := time.Date(2026, 2, 24, 10, 0, 1, 0, time.UTC)
writeJSONL(t, filepath.Join(dir, "nomatch.jsonl"), []any{
map[string]any{
"type": "assistant", "timestamp": ts1.Format(time.RFC3339Nano),
"message": map[string]any{
"role": "assistant",
"content": []any{
map[string]any{
"type": "tool_use", "id": "tu_3", "name": "Bash",
"input": map[string]any{"command": "ls"},
},
},
},
},
map[string]any{
"type": "user", "timestamp": ts2.Format(time.RFC3339Nano),
"message": map[string]any{
"role": "user",
"content": []any{
map[string]any{
"type": "tool_result", "tool_use_id": "tu_3",
"content": "file.txt",
},
},
},
},
})
results, err := Search(dir, "nonexistent query")
if err != nil {
t.Fatal(err)
}
if len(results) != 0 {
t.Fatalf("expected 0 results, got %d", len(results))
}
}
func TestSearch_Good_EmptyDir(t *testing.T) {
dir := t.TempDir()
results, err := Search(dir, "anything")
if err != nil {
t.Fatal(err)
}
if len(results) != 0 {
t.Fatalf("expected 0, got %d", len(results))
}
}
func TestSearch_Good_SkipsNonToolEvents(t *testing.T) {
dir := t.TempDir()
ts := time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC)
writeJSONL(t, filepath.Join(dir, "skip.jsonl"), []any{
map[string]any{
"type": "user", "timestamp": ts.Format(time.RFC3339Nano),
"message": map[string]any{
"role": "user",
"content": []any{
map[string]any{"type": "text", "text": "go test should find this"},
},
},
},
})
results, err := Search(dir, "go test")
if err != nil {
t.Fatal(err)
}
if len(results) != 0 {
t.Fatal("search should only match tool_use events")
}
}

189
pkg/session/video_test.go Normal file
View file

@ -0,0 +1,189 @@
package session
import (
"strings"
"testing"
"time"
)
// ── RenderMP4 ──────────────────────────────────────────────────────
func TestRenderMP4_Bad_VHSNotInstalled(t *testing.T) {
// Save PATH, set to empty so vhs won't be found.
origPath := t.TempDir() // use empty dir as PATH
t.Setenv("PATH", origPath)
sess := &Session{ID: "test", StartTime: time.Now(), Events: []Event{}}
err := RenderMP4(sess, "/tmp/test.mp4")
if err == nil {
t.Fatal("expected error when vhs not installed")
}
if !strings.Contains(err.Error(), "vhs not installed") {
t.Fatalf("unexpected error: %s", err)
}
}
// ── extractCommand ─────────────────────────────────────────────────
func TestExtractCommand_Good_WithDesc(t *testing.T) {
got := extractCommand("go test ./... # run tests")
if got != "go test ./..." {
t.Fatalf("expected 'go test ./...', got %s", got)
}
}
func TestExtractCommand_Good_NoDesc(t *testing.T) {
got := extractCommand("ls -la")
if got != "ls -la" {
t.Fatalf("expected ls -la, got %s", got)
}
}
func TestExtractCommand_Good_Empty(t *testing.T) {
got := extractCommand("")
if got != "" {
t.Fatalf("expected empty, got %s", got)
}
}
func TestExtractCommand_Good_HashInMiddle(t *testing.T) {
got := extractCommand(`echo "hello # world" # desc`)
if got != `echo "hello` {
// Note: the simple split finds first " # " occurrence.
t.Fatalf("unexpected: %s", got)
}
}
// ── generateTape ───────────────────────────────────────────────────
func TestGenerateTape_Good_BasicSession(t *testing.T) {
sess := &Session{
ID: "f3fb074c-8c72-4da6-a15a-85bae652ccaa",
StartTime: time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC),
Events: []Event{
{
Type: "tool_use", Tool: "Bash",
Input: "go test ./...", Output: "ok 1.2s",
Success: true,
},
{
Type: "tool_use", Tool: "Read",
Input: "/tmp/test.go",
},
{
Type: "tool_use", Tool: "Task",
Input: "[Explore] find tests",
},
},
}
tape := generateTape(sess, "/tmp/out.mp4")
if !strings.Contains(tape, "Output /tmp/out.mp4") {
t.Fatal("missing output directive")
}
if !strings.Contains(tape, "f3fb074c") {
t.Fatal("missing session ID")
}
if !strings.Contains(tape, "go test ./...") {
t.Fatal("missing bash command")
}
if !strings.Contains(tape, "Read: /tmp/test.go") {
t.Fatal("missing Read event")
}
if !strings.Contains(tape, "Agent:") {
t.Fatal("missing Task/Agent event")
}
if !strings.Contains(tape, "OK") {
t.Fatal("missing success indicator")
}
}
func TestGenerateTape_Good_FailedCommand(t *testing.T) {
sess := &Session{
ID: "fail-test",
StartTime: time.Now(),
Events: []Event{
{
Type: "tool_use", Tool: "Bash",
Input: "false", Output: "exit 1",
Success: false,
},
},
}
tape := generateTape(sess, "/tmp/fail.mp4")
if !strings.Contains(tape, "FAILED") {
t.Fatal("missing FAILED indicator")
}
}
func TestGenerateTape_Good_EmptySession(t *testing.T) {
sess := &Session{ID: "empty", StartTime: time.Now()}
tape := generateTape(sess, "/tmp/empty.mp4")
if !strings.Contains(tape, "Output /tmp/empty.mp4") {
t.Fatal("missing output directive")
}
if !strings.Contains(tape, "Sleep 3s") {
t.Fatal("missing final sleep")
}
}
func TestGenerateTape_Good_LongOutput(t *testing.T) {
longOutput := strings.Repeat("x", 300)
sess := &Session{
ID: "long",
StartTime: time.Now(),
Events: []Event{
{
Type: "tool_use", Tool: "Bash",
Input: "cat bigfile", Output: longOutput,
Success: true,
},
},
}
tape := generateTape(sess, "/tmp/long.mp4")
// Output should be truncated to 200 chars + "..."
if strings.Contains(tape, strings.Repeat("x", 300)) {
t.Fatal("output not truncated")
}
}
func TestGenerateTape_Good_SkipsNonToolEvents(t *testing.T) {
sess := &Session{
ID: "mixed",
StartTime: time.Now(),
Events: []Event{
{Type: "user", Input: "hello"},
{Type: "assistant", Input: "hi there"},
{Type: "tool_use", Tool: "Bash", Input: "echo hi", Output: "hi", Success: true},
},
}
tape := generateTape(sess, "/tmp/mixed.mp4")
if strings.Contains(tape, "hello") {
t.Fatal("user message should be skipped")
}
if strings.Contains(tape, "hi there") {
t.Fatal("assistant message should be skipped")
}
if !strings.Contains(tape, "echo hi") {
t.Fatal("bash command should be present")
}
}
func TestGenerateTape_Good_EmptyBashCommand(t *testing.T) {
sess := &Session{
ID: "empty-cmd",
StartTime: time.Now(),
Events: []Event{
{Type: "tool_use", Tool: "Bash", Input: "", Success: true},
},
}
tape := generateTape(sess, "/tmp/empty-cmd.mp4")
// Empty command should be skipped — no "$ " line.
if strings.Contains(tape, "$ ") {
t.Fatal("empty command should be skipped")
}
}