diff --git a/cmd/sin-code/internal/autodev/autodev_test.go b/cmd/sin-code/internal/autodev/autodev_test.go index 52524588..2cc0c1a3 100644 --- a/cmd/sin-code/internal/autodev/autodev_test.go +++ b/cmd/sin-code/internal/autodev/autodev_test.go @@ -198,3 +198,72 @@ func TestVersion_PropagatesContextDeadline(t *testing.T) { t.Errorf("versionWith(cancelled ctx) = (value, nil), want err on cancelled ctx") } } + +func TestResolveAutodevBin_OK(t *testing.T) { + dir := t.TempDir() + writeFakeBinary(t, dir, "autodev-fake", "ok", 0) + t.Setenv("AUTODEV_BIN", "autodev-fake") + t.Setenv("PATH", prependPath(dir)) + if err := ResolveAutodevBin(); err != nil { + t.Errorf("ResolveAutodevBin() = %v, want nil", err) + } +} + +func TestResolveAutodevBin_NotInstalled(t *testing.T) { + t.Setenv("AUTODEV_BIN", "definitely-not-installed-xyzzy") + t.Setenv("PATH", t.TempDir()) + err := ResolveAutodevBin() + if err == nil { + t.Fatal("ResolveAutodevBin() = nil, want non-nil") + } + if !errors.Is(err, ErrNotInstalled) { + t.Errorf("err = %v, does not wrap ErrNotInstalled", err) + } +} + +func TestResolve_EmptyBin(t *testing.T) { + // Direct coverage of the shared resolve() empty-name branch. + err := resolve("") + if err == nil { + t.Fatal("resolve(\"\") = nil, want non-nil") + } + if !errors.Is(err, ErrNotInstalled) { + t.Errorf("err = %v, does not wrap ErrNotInstalled", err) + } +} + +func TestVersion_EmptyStdout(t *testing.T) { + // Successful exit with no output -> explicit empty-stdout error. + dir := t.TempDir() + writeFakeBinary(t, dir, "autodev-empty", "", 0) + t.Setenv("AUTODEV_BIN", "autodev-empty") + t.Setenv("PATH", prependPath(dir)) + + got, err := Version() + if err == nil { + t.Fatal("Version() err = nil, want non-nil on empty stdout") + } + if got != "" { + t.Errorf("Version() = %q, want empty string", got) + } + if !strings.Contains(err.Error(), "returned empty stdout") { + t.Errorf("Version() err %q missing expected text", err.Error()) + } +} + +func TestVersion_NoOutputError(t *testing.T) { + // Non-zero exit with no stdout/stderr -> the wrapped exec error is + // used as the diagnostic payload. + dir := t.TempDir() + writeFakeBinary(t, dir, "autodev-silent", "", 1) + t.Setenv("AUTODEV_BIN", "autodev-silent") + t.Setenv("PATH", prependPath(dir)) + + _, err := Version() + if err == nil { + t.Fatal("Version() err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "exit status 1") { + t.Errorf("Version() err %q missing wrapped exec error", err.Error()) + } +} diff --git a/cmd/sin-code/internal/catalog/catalog_test.go b/cmd/sin-code/internal/catalog/catalog_test.go index dfc24082..c44a3a4f 100644 --- a/cmd/sin-code/internal/catalog/catalog_test.go +++ b/cmd/sin-code/internal/catalog/catalog_test.go @@ -5,18 +5,25 @@ package catalog import ( "context" + "errors" "strings" "testing" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/assets" ) // fakeSource is a minimal Source implementation for tests. type fakeSource struct { - name string - assets []*Asset + name string + assets []*Asset + listErr error } func (f *fakeSource) Name() string { return f.name } func (f *fakeSource) List(_ context.Context, kind Kind) ([]*Asset, error) { + if f.listErr != nil { + return nil, f.listErr + } if kind == "" { // Filter out nils so callers don't have to. out := make([]*Asset, 0, len(f.assets)) @@ -325,10 +332,9 @@ func TestSearch_StableOrderOnTie(t *testing.T) { func TestMerge_NilAssetSkipped(t *testing.T) { // A Source that returns a nil entry in the slice must not crash // the merger. The catalog's contract is to skip nil entries. - src := &fakeSource{ - name: "a", - assets: []*Asset{{Kind: KindCommand, Name: "ok"}, nil}, - } + // Use a custom source that does NOT filter nils so the nil reaches + // Merge's own skip logic. + src := &nilSource{assets: []*Asset{{Kind: KindCommand, Name: "ok"}, nil}} got, err := Merge(context.Background(), []Source{src}) if err != nil { t.Fatal(err) @@ -338,6 +344,16 @@ func TestMerge_NilAssetSkipped(t *testing.T) { } } +type nilSource struct{ assets []*Asset } + +func (n *nilSource) Name() string { return "nil-src" } +func (n *nilSource) List(_ context.Context, _ Kind) ([]*Asset, error) { + return n.assets, nil +} +func (n *nilSource) Get(_ context.Context, _ Kind, _ string) (*Asset, bool, error) { + return nil, false, nil +} + func TestMerge_SearchEndToEnd(t *testing.T) { // Full integration: two sources, merged, then searched. hub := &fakeSource{name: "hub", assets: []*Asset{ @@ -419,3 +435,184 @@ func TestMerge_OutputFormat(t *testing.T) { t.Errorf("expected source=hub, got %s", merged[0].Source) } } + +func TestMerge_SourceError(t *testing.T) { + errSrc := &fakeSource{ + name: "err", + listErr: errors.New("boom"), + } + _, err := Merge(context.Background(), []Source{errSrc}) + if err == nil { + t.Fatal("expected error from Source.List") + } +} + +func TestHubSource_Name(t *testing.T) { + if got := (HubSource{}).Name(); got != "hub" { + t.Errorf("expected hub, got %s", got) + } +} + +func TestHubSource_GetWrongKind(t *testing.T) { + _, ok, err := HubSource{}.Get(context.Background(), KindAgent, "chat") + if err != nil { + t.Fatal(err) + } + if ok { + t.Error("expected not-found for non-hub kind") + } +} + +func TestAssetsSource_Name(t *testing.T) { + if got := NewAssetsSource(nil).Name(); got != "assets" { + t.Errorf("expected assets, got %s", got) + } +} + +func TestAssetsSource_ListAndGet(t *testing.T) { + reg := assets.NewRegistry() + reg.Add(&assets.Asset{Kind: assets.KindCommand, Name: "read", Description: "read files", Domain: "io"}) + reg.Add(&assets.Asset{Kind: assets.KindAgent, Name: "go-reviewer", Description: "review", Domain: "go"}) + reg.Add(&assets.Asset{Kind: assets.KindSkill, Name: "lazy", Description: "lazy skill", Domain: "code"}) + + src := NewAssetsSource(reg) + + all, err := src.List(context.Background(), "") + if err != nil { + t.Fatal(err) + } + if len(all) != 3 { + t.Errorf("expected 3 assets, got %d", len(all)) + } + + agents, err := src.List(context.Background(), KindAgent) + if err != nil { + t.Fatal(err) + } + if len(agents) != 1 || agents[0].Name != "go-reviewer" { + t.Errorf("expected 1 agent, got %+v", agents) + } + + skills, err := src.List(context.Background(), KindSkill) + if err != nil { + t.Fatal(err) + } + if len(skills) != 1 || skills[0].Name != "lazy" { + t.Errorf("expected 1 skill, got %+v", skills) + } + + cmds, err := src.List(context.Background(), KindCommand) + if err != nil { + t.Fatal(err) + } + if len(cmds) != 1 || cmds[0].Name != "read" { + t.Errorf("expected 1 command, got %+v", cmds) + } + + got, ok, err := src.Get(context.Background(), KindCommand, "read") + if err != nil || !ok { + t.Fatalf("expected read, got ok=%v err=%v", ok, err) + } + if got.Name != "read" || got.Source != "assets" || got.Domain != "io" { + t.Errorf("unexpected asset: %+v", got) + } + + _, ok, err = src.Get(context.Background(), KindCommand, "missing") + if err != nil || ok { + t.Errorf("expected not-found") + } + + _, ok, err = src.Get(context.Background(), KindHub, "read") + if err != nil || ok { + t.Errorf("expected not-found for non-mapped kind") + } + + _, ok, err = src.Get(context.Background(), KindAgent, "go-reviewer") + if err != nil || !ok { + t.Errorf("expected agent, got ok=%v err=%v", ok, err) + } + + _, ok, err = src.Get(context.Background(), KindSkill, "lazy") + if err != nil || !ok { + t.Errorf("expected skill, got ok=%v err=%v", ok, err) + } +} + +func TestAssetsSource_GetNilRegistry(t *testing.T) { + src := NewAssetsSource(nil) + _, ok, err := src.Get(context.Background(), KindCommand, "x") + if err != nil || ok { + t.Errorf("expected not-found from nil registry") + } +} + +func TestConvertAllAndConvertOne(t *testing.T) { + all := convertAll([]*assets.Asset{ + nil, + {Kind: assets.KindCommand, Name: "ok", Description: "ok"}, + }) + if len(all) != 2 || all[0] != nil || all[1].Name != "ok" { + t.Errorf("convertAll did not preserve nil: %+v", all) + } + if convertOne(nil) != nil { + t.Error("convertOne(nil) should be nil") + } +} + +func TestKindMap(t *testing.T) { + cases := []struct { + in assets.Kind + want Kind + }{ + {assets.KindAgent, KindAgent}, + {assets.KindCommand, KindCommand}, + {assets.KindSkill, KindSkill}, + {assets.Kind("unknown"), ""}, + } + for _, c := range cases { + if got := kindMap(c.in); got != c.want { + t.Errorf("kindMap(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestFilterByKind_NoMatch(t *testing.T) { + assets := []*Asset{{Kind: KindCommand, Name: "a"}} + got := FilterByKind(assets, KindSkill) + if len(got) != 0 { + t.Errorf("expected 0, got %d", len(got)) + } +} + +func TestSearch_DescriptionMatch(t *testing.T) { + assets := []*Asset{ + {Name: "x", Description: "find me"}, + {Name: "y", Description: "other"}, + } + got := Search(assets, "find") + if len(got) != 1 || got[0].Name != "x" { + t.Errorf("expected x by description, got %+v", got) + } +} + +func TestSearch_ShortMatch(t *testing.T) { + assets := []*Asset{ + {Name: "x", Short: "shorty"}, + {Name: "y", Short: "other"}, + } + got := Search(assets, "short") + if len(got) != 1 || got[0].Name != "x" { + t.Errorf("expected x by short, got %+v", got) + } +} + +func TestSearch_OnlyTags(t *testing.T) { + assets := []*Asset{ + {Name: "x", Tags: []string{"go"}}, + {Name: "y", Tags: []string{"rust"}}, + } + got := Search(assets, "go") + if len(got) != 1 || got[0].Name != "x" { + t.Errorf("expected x by tag, got %+v", got) + } +} diff --git a/cmd/sin-code/internal/codegraph/codegraph_test.go b/cmd/sin-code/internal/codegraph/codegraph_test.go index cc8ec908..db59bba5 100644 --- a/cmd/sin-code/internal/codegraph/codegraph_test.go +++ b/cmd/sin-code/internal/codegraph/codegraph_test.go @@ -4,9 +4,12 @@ package codegraph import ( "context" "errors" + "fmt" "os" "path/filepath" + "strings" "testing" + "time" ) func TestParseGraph(t *testing.T) { @@ -116,3 +119,209 @@ func TestRunNoArgs(t *testing.T) { t.Error("expected error for empty args") } } + +// writeFakeCodegraph installs a POSIX shell script at dir/name that prints +// stdout (shell-quoted) and exits with code. +func writeFakeCodegraph(t *testing.T, dir, name, stdout string, code int) string { + t.Helper() + body := fmt.Sprintf("#!/bin/sh\necho %q\nexit %d\n", stdout, code) + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + return p +} + +func TestFindCached(t *testing.T) { + dir := t.TempDir() + fake := writeFakeCodegraph(t, dir, "codegraph", "v1", 0) + b := &Bridge{lookPath: func(string) (string, error) { return fake, nil }, candidates: nil} + got1, err := b.Find() + if err != nil { + t.Fatalf("Find: %v", err) + } + if got1 != fake { + t.Errorf("Find = %q, want %q", got1, fake) + } + got2, err := b.Find() + if err != nil { + t.Fatalf("Find second: %v", err) + } + if got2 != fake { + t.Errorf("Find second = %q, want cached %q", got2, fake) + } +} + +func TestFindStaleCache(t *testing.T) { + dir := t.TempDir() + fake := writeFakeCodegraph(t, dir, "codegraph", "v1", 0) + missing := filepath.Join(dir, "missing") + b := &Bridge{ + lookPath: func(string) (string, error) { return fake, nil }, + candidates: nil, + cached: missing, + } + got, err := b.Find() + if err != nil { + t.Fatalf("Find: %v", err) + } + if got != fake { + t.Errorf("Find = %q, want %q", got, fake) + } + if b.cached != fake { + t.Errorf("cached = %q, want %q", b.cached, fake) + } +} + +func TestFindNilLookPath(t *testing.T) { + // Cover the lp == nil fallback and the empty-candidate skip branch. + t.Setenv("PATH", "") + dir := t.TempDir() + fake := writeFakeCodegraph(t, dir, "codegraph", "v1", 0) + b := &Bridge{lookPath: nil, candidates: []string{"", fake}} + got, err := b.Find() + if err != nil { + t.Fatalf("Find: %v", err) + } + if got != fake { + t.Errorf("Find = %q, want %q", got, fake) + } +} + +func TestFindAllCandidatesMissing(t *testing.T) { + b := &Bridge{ + lookPath: func(string) (string, error) { return "", errors.New("not found") }, + candidates: []string{ + filepath.Join(t.TempDir(), "a"), + filepath.Join(t.TempDir(), "b"), + }, + } + if _, err := b.Find(); !errors.Is(err, ErrNotInstalled) { + t.Errorf("Find err = %v, want ErrNotInstalled", err) + } +} + +func TestRunHappy(t *testing.T) { + dir := t.TempDir() + fake := writeFakeCodegraph(t, dir, "codegraph", "hello world", 0) + b := &Bridge{lookPath: func(string) (string, error) { return fake, nil }, candidates: nil} + out, err := b.Run(context.Background(), "", []string{"--version"}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if out != "hello world" { + t.Errorf("Run = %q, want %q", out, "hello world") + } +} + +func TestRunTimeout(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "codegraph") + if err := os.WriteFile(p, []byte("#!/bin/sh\nsleep 5\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + b := &Bridge{lookPath: func(string) (string, error) { return p, nil }, candidates: nil} + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + _, err := b.Run(ctx, ".", []string{"analyze"}) + if err == nil { + t.Fatal("Run: expected timeout error") + } + if !strings.Contains(err.Error(), "timed out") { + t.Errorf("Run err = %q, want 'timed out'", err.Error()) + } +} + +func TestRunError(t *testing.T) { + dir := t.TempDir() + fake := writeFakeCodegraph(t, dir, "codegraph", "boom", 1) + b := &Bridge{lookPath: func(string) (string, error) { return fake, nil }, candidates: nil} + _, err := b.Run(context.Background(), ".", []string{"bad"}) + if err == nil { + t.Fatal("Run: expected error") + } + if !strings.Contains(err.Error(), "bad") { + t.Errorf("Run err = %q, want command in error", err.Error()) + } +} + +func TestRunNotInstalled(t *testing.T) { + b := &Bridge{lookPath: func(string) (string, error) { return "", errors.New("not found") }, candidates: nil} + _, err := b.Run(context.Background(), ".", []string{"--version"}) + if !errors.Is(err, ErrNotInstalled) { + t.Errorf("Run err = %v, want ErrNotInstalled", err) + } +} + +func TestAnalyzeHappy(t *testing.T) { + dir := t.TempDir() + out := `{"root":"original","nodes":[],"edges":[]}` + fake := writeFakeCodegraph(t, dir, "codegraph", out, 0) + b := &Bridge{lookPath: func(string) (string, error) { return fake, nil }, candidates: nil} + g, err := b.Analyze(context.Background(), dir) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if g.Root != "original" { + t.Errorf("Analyze root = %q, want %q", g.Root, "original") + } +} + +func TestAnalyzeEmptyPath(t *testing.T) { + dir := t.TempDir() + out := `{"root":"p","nodes":[],"edges":[]}` + fake := writeFakeCodegraph(t, dir, "codegraph", out, 0) + b := &Bridge{lookPath: func(string) (string, error) { return fake, nil }, candidates: nil} + g, err := b.Analyze(context.Background(), "") + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if g.Root != "p" { + t.Errorf("Analyze root = %q, want %q", g.Root, "p") + } +} + +func TestAnalyzeEmptyRoot(t *testing.T) { + dir := t.TempDir() + out := `{"nodes":[],"edges":[]}` + fake := writeFakeCodegraph(t, dir, "codegraph", out, 0) + b := &Bridge{lookPath: func(string) (string, error) { return fake, nil }, candidates: nil} + g, err := b.Analyze(context.Background(), dir) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if g.Root != dir { + t.Errorf("Analyze root = %q, want %q", g.Root, dir) + } +} + +func TestAnalyzeRunError(t *testing.T) { + dir := t.TempDir() + fake := writeFakeCodegraph(t, dir, "codegraph", "fail", 1) + b := &Bridge{lookPath: func(string) (string, error) { return fake, nil }, candidates: nil} + if _, err := b.Analyze(context.Background(), "."); err == nil { + t.Fatal("Analyze: expected error") + } +} + +func TestAnalyzeParseError(t *testing.T) { + dir := t.TempDir() + fake := writeFakeCodegraph(t, dir, "codegraph", "not json", 0) + b := &Bridge{lookPath: func(string) (string, error) { return fake, nil }, candidates: nil} + if _, err := b.Analyze(context.Background(), "."); err == nil { + t.Fatal("Analyze: expected error") + } +} + +func TestVersionHappy(t *testing.T) { + dir := t.TempDir() + fake := writeFakeCodegraph(t, dir, "codegraph", "1.2.3", 0) + b := &Bridge{lookPath: func(string) (string, error) { return fake, nil }, candidates: nil} + v, err := b.Version(context.Background()) + if err != nil { + t.Fatalf("Version: %v", err) + } + if v != "1.2.3" { + t.Errorf("Version = %q, want %q", v, "1.2.3") + } +} diff --git a/cmd/sin-code/internal/learning/coverage_test.go b/cmd/sin-code/internal/learning/coverage_test.go index 9ec017bc..9a9a18e4 100644 --- a/cmd/sin-code/internal/learning/coverage_test.go +++ b/cmd/sin-code/internal/learning/coverage_test.go @@ -3,6 +3,8 @@ package learning import ( "context" "os" + "path/filepath" + "strings" "testing" "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/agentloop" @@ -156,6 +158,58 @@ func TestEndTurnAndPreCompact(t *testing.T) { } } +func TestSetStyle(t *testing.T) { + t.Setenv("SIN_INSTINCT_DIR", t.TempDir()) + l, err := New(Options{Workdir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + + // Default style returns empty when no instincts are active. + if got := l.BeforeTurn(context.Background(), &session.Session{}); got != "" { + t.Fatalf("expected empty block with default style, got %q", got) + } + + // Switch to terse mode; the style block should be emitted even with no active instincts. + l.SetStyle("terse") + got := l.BeforeTurn(context.Background(), &session.Session{}) + if !strings.Contains(got, "# Output style") { + t.Fatalf("expected style block after SetStyle(terse), got %q", got) + } + + // Revert to default. + l.SetStyle("") + if got := l.BeforeTurn(context.Background(), &session.Session{}); got != "" { + t.Fatalf("expected empty block after reverting to default, got %q", got) + } +} + +func TestBeforeTurnActiveErrorFallsBackToStyle(t *testing.T) { + instinctDir := t.TempDir() + t.Setenv("SIN_INSTINCT_DIR", instinctDir) + workdir := t.TempDir() + l, err := New(Options{Workdir: workdir}) + if err != nil { + t.Fatal(err) + } + + // Corrupt the project's instinct directory so Manager.Active() returns an error. + projID := l.Manager().Project().ID + dir := filepath.Join(instinctDir, "projects", projID, "instincts") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "corrupt.md"), []byte("not valid instinct"), 0o644); err != nil { + t.Fatal(err) + } + + l.SetStyle("terse") + got := l.BeforeTurn(context.Background(), &session.Session{}) + if !strings.Contains(got, "# Output style") { + t.Fatalf("expected style block even when Active() errors, got %q", got) + } +} + func TestWrap(t *testing.T) { t.Setenv("SIN_INSTINCT_DIR", t.TempDir()) l, err := New(Options{Workdir: t.TempDir()}) diff --git a/cmd/sin-code/internal/mcpclient/manager.go b/cmd/sin-code/internal/mcpclient/manager.go index f1de68ad..8b15bda3 100644 --- a/cmd/sin-code/internal/mcpclient/manager.go +++ b/cmd/sin-code/internal/mcpclient/manager.go @@ -21,6 +21,14 @@ var ( warningOnce sync.Once warnedServers = make(map[string]bool) warnedMu sync.Mutex + + // connectTransportHook lets tests inject an in-memory MCP transport so the + // connection success path can be exercised without spawning subprocesses. + connectTransportHook func(ctx context.Context, cfg ServerConfig) (sdk.Transport, error) + + // listToolsHook lets tests inject a ListTools error without building a + // custom transport/connection implementation. + listToolsHook func(ctx context.Context, sess *sdk.ClientSession) (*sdk.ListToolsResult, error) ) type ServerConfig struct { @@ -90,11 +98,24 @@ func (m *Manager) connect(ctx context.Context, client *sdk.Client, cfg ServerCon return fmt.Errorf("unknown transport %q", cfg.Transport) } + if connectTransportHook != nil { + if t, err := connectTransportHook(ctx, cfg); err != nil { + return err + } else if t != nil { + transport = t + } + } + sess, err := client.Connect(ctx, transport, nil) if err != nil { return err } - res, err := sess.ListTools(ctx, nil) + var res *sdk.ListToolsResult + if listToolsHook != nil { + res, err = listToolsHook(ctx, sess) + } else { + res, err = sess.ListTools(ctx, nil) + } if err != nil { _ = sess.Close() return err diff --git a/cmd/sin-code/internal/mcpclient/manager_test.go b/cmd/sin-code/internal/mcpclient/manager_test.go index 07b9a9d6..5bc2d627 100644 --- a/cmd/sin-code/internal/mcpclient/manager_test.go +++ b/cmd/sin-code/internal/mcpclient/manager_test.go @@ -7,11 +7,14 @@ package mcpclient import ( "bytes" "context" + "errors" "os" "strings" "sync" "testing" "time" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" ) // captureStderr redirects os.Stderr for the duration of fn. @@ -120,3 +123,207 @@ func TestToolsConcurrentAccessRaceClean(t *testing.T) { } wg.Wait() } + +// startTestServer starts an MCP server on an in-memory transport and returns +// a hook that hands the paired client-side transport to Manager.connect. +func startTestServer(t *testing.T, srv *sdk.Server) func(context.Context, ServerConfig) (sdk.Transport, error) { + t.Helper() + serverTrans, clientTrans := sdk.NewInMemoryTransports() + ctx, cancel := context.WithCancel(context.Background()) + var runDone sync.WaitGroup + runDone.Add(1) + go func() { + defer runDone.Done() + _ = srv.Run(ctx, serverTrans) + }() + t.Cleanup(func() { + cancel() + runDone.Wait() + }) + return func(context.Context, ServerConfig) (sdk.Transport, error) { + return clientTrans, nil + } +} + +func TestConnectAllSuccessful(t *testing.T) { + orig := connectTransportHook + t.Cleanup(func() { connectTransportHook = orig }) + + srv := sdk.NewServer(&sdk.Implementation{Name: "test-server", Version: "1.0.0"}, nil) + sdk.AddTool(srv, &sdk.Tool{Name: "greet", Description: "say hi"}, func(ctx context.Context, _ *sdk.CallToolRequest, _ any) (*sdk.CallToolResult, any, error) { + return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: "hello"}}}, nil, nil + }) + connectTransportHook = startTestServer(t, srv) + + mgr := NewManager([]ServerConfig{{Name: "test", Transport: "stdio", Command: "unused", Env: map[string]string{"K": "V"}}}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := mgr.ConnectAll(ctx); err != nil { + t.Fatalf("ConnectAll: %v", err) + } + tools := mgr.Tools() + if len(tools) != 1 || tools[0].Qualified != "test__greet" { + t.Fatalf("expected 1 tool, got %+v", tools) + } +} + +func TestConnectAllSuccessfulHTTP(t *testing.T) { + orig := connectTransportHook + t.Cleanup(func() { connectTransportHook = orig }) + + srv := sdk.NewServer(&sdk.Implementation{Name: "test-http", Version: "1.0.0"}, nil) + sdk.AddTool(srv, &sdk.Tool{Name: "ping", Description: "ping"}, func(ctx context.Context, _ *sdk.CallToolRequest, _ any) (*sdk.CallToolResult, any, error) { + return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: "pong"}}}, nil, nil + }) + connectTransportHook = startTestServer(t, srv) + + mgr := NewManager([]ServerConfig{{Name: "http", Transport: "http", URL: "http://unused"}}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := mgr.ConnectAll(ctx); err != nil { + t.Fatalf("ConnectAll: %v", err) + } + if tools := mgr.Tools(); len(tools) != 1 || tools[0].Name != "ping" { + t.Fatalf("expected ping tool, got %+v", tools) + } +} + +func TestConnectAllDuplicateWarning(t *testing.T) { + orig := connectTransportHook + t.Cleanup(func() { connectTransportHook = orig }) + origWarned := warnedServers + t.Cleanup(func() { warnedServers = origWarned }) + + warnedServers = map[string]bool{"dup": true} + connectTransportHook = func(context.Context, ServerConfig) (sdk.Transport, error) { + return nil, errors.New("boom") + } + + mgr := NewManager([]ServerConfig{{Name: "dup", Transport: "stdio", Command: "unused"}}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + logged := captureStderr(t, func() { _ = mgr.ConnectAll(ctx) }) + if logged != "" { + t.Fatalf("expected no duplicate warning, got: %q", logged) + } +} + +func TestConnectAllListToolsError(t *testing.T) { + orig := connectTransportHook + t.Cleanup(func() { connectTransportHook = orig }) + origListTools := listToolsHook + t.Cleanup(func() { listToolsHook = origListTools }) + + srv := sdk.NewServer(&sdk.Implementation{Name: "no-tools", Version: "1.0.0"}, nil) + connectTransportHook = startTestServer(t, srv) + listToolsHook = func(context.Context, *sdk.ClientSession) (*sdk.ListToolsResult, error) { + return nil, errors.New("list tools boom") + } + + mgr := NewManager([]ServerConfig{{Name: "no-tools", Transport: "stdio", Command: "unused"}}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + logged := captureStderr(t, func() { _ = mgr.ConnectAll(ctx) }) + if !strings.Contains(logged, "no-tools") { + t.Fatalf("expected warning for no-tools, got: %s", logged) + } +} + +func TestCallSuccessful(t *testing.T) { + orig := connectTransportHook + t.Cleanup(func() { connectTransportHook = orig }) + + srv := sdk.NewServer(&sdk.Implementation{Name: "call", Version: "1.0.0"}, nil) + sdk.AddTool(srv, &sdk.Tool{Name: "echo", Description: "echo"}, func(ctx context.Context, req *sdk.CallToolRequest, args any) (*sdk.CallToolResult, any, error) { + return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: "echoed"}}}, nil, nil + }) + connectTransportHook = startTestServer(t, srv) + + mgr := NewManager([]ServerConfig{{Name: "call", Transport: "stdio", Command: "unused"}}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := mgr.ConnectAll(ctx); err != nil { + t.Fatalf("ConnectAll: %v", err) + } + out, err := mgr.Call(ctx, "call__echo", map[string]any{}) + if err != nil { + t.Fatalf("Call: %v", err) + } + if out != "echoed" { + t.Fatalf("expected echoed, got %q", out) + } +} + +func TestCallToolError(t *testing.T) { + orig := connectTransportHook + t.Cleanup(func() { connectTransportHook = orig }) + + srv := sdk.NewServer(&sdk.Implementation{Name: "err", Version: "1.0.0"}, nil) + sdk.AddTool(srv, &sdk.Tool{Name: "ok", Description: "ok"}, func(ctx context.Context, req *sdk.CallToolRequest, args any) (*sdk.CallToolResult, any, error) { + return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: "ok"}}}, nil, nil + }) + connectTransportHook = startTestServer(t, srv) + + mgr := NewManager([]ServerConfig{{Name: "err", Transport: "stdio", Command: "unused"}}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := mgr.ConnectAll(ctx); err != nil { + t.Fatalf("ConnectAll: %v", err) + } + if _, err := mgr.Call(ctx, "err__missing", map[string]any{}); err == nil { + t.Fatal("expected error for missing tool") + } +} + +func TestCallToolReturnsError(t *testing.T) { + orig := connectTransportHook + t.Cleanup(func() { connectTransportHook = orig }) + + srv := sdk.NewServer(&sdk.Implementation{Name: "tool-err", Version: "1.0.0"}, nil) + sdk.AddTool(srv, &sdk.Tool{Name: "fail", Description: "fail"}, func(ctx context.Context, req *sdk.CallToolRequest, args any) (*sdk.CallToolResult, any, error) { + return nil, nil, errors.New("tool failure") + }) + connectTransportHook = startTestServer(t, srv) + + mgr := NewManager([]ServerConfig{{Name: "tool-err", Transport: "stdio", Command: "unused"}}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := mgr.ConnectAll(ctx); err != nil { + t.Fatalf("ConnectAll: %v", err) + } + if _, err := mgr.Call(ctx, "tool-err__fail", map[string]any{}); err == nil { + t.Fatal("expected error for tool failure") + } +} + +func TestIsExternal(t *testing.T) { + mgr := NewManager(nil) + if !mgr.IsExternal("server__tool") { + t.Error("expected server__tool to be external") + } + if mgr.IsExternal("sin_read") { + t.Error("expected sin_read not to be external") + } +} + +func TestClose(t *testing.T) { + orig := connectTransportHook + t.Cleanup(func() { connectTransportHook = orig }) + + srv := sdk.NewServer(&sdk.Implementation{Name: "close", Version: "1.0.0"}, nil) + sdk.AddTool(srv, &sdk.Tool{Name: "x", Description: "x"}, func(ctx context.Context, _ *sdk.CallToolRequest, _ any) (*sdk.CallToolResult, any, error) { + return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: "x"}}}, nil, nil + }) + connectTransportHook = startTestServer(t, srv) + + mgr := NewManager([]ServerConfig{{Name: "close", Transport: "stdio", Command: "unused"}}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := mgr.ConnectAll(ctx); err != nil { + t.Fatalf("ConnectAll: %v", err) + } + mgr.Close() + if _, err := mgr.Call(ctx, "close__x", nil); err == nil { + t.Fatal("expected error after close") + } +} diff --git a/cmd/sin-code/internal/mcpclient/registry.go b/cmd/sin-code/internal/mcpclient/registry.go index db339d67..b5960f1e 100644 --- a/cmd/sin-code/internal/mcpclient/registry.go +++ b/cmd/sin-code/internal/mcpclient/registry.go @@ -10,6 +10,12 @@ import ( "path/filepath" ) +var ( + // userHomeDirHook lets tests force the default skills-dir path to empty + // so the PATH fallback branches in DefaultServers are exercised. + userHomeDirHook = os.UserHomeDir +) + // DefaultServers returns the ecosystem registry. Server names double as // tool-name prefixes ("websearch__search", "browser__navigate", ...), which // the permission matrix gates via the "mcp" policy class. @@ -96,6 +102,9 @@ func skillsDirOrDefault() string { if d := os.Getenv("SIN_SKILLS_DIR"); d != "" { return d } - home, _ := os.UserHomeDir() + home, err := userHomeDirHook() + if err != nil || home == "" { + return "" + } return filepath.Join(home, ".local", "share", "sin-code", "skills") } diff --git a/cmd/sin-code/internal/mcpclient/registry_test.go b/cmd/sin-code/internal/mcpclient/registry_test.go index dfa20435..c43a4748 100644 --- a/cmd/sin-code/internal/mcpclient/registry_test.go +++ b/cmd/sin-code/internal/mcpclient/registry_test.go @@ -3,6 +3,7 @@ package mcpclient import ( + "errors" "os" "path/filepath" "testing" @@ -74,3 +75,48 @@ func TestDefaultServersWebsearchUsesDefaultSkillsDir(t *testing.T) { } t.Fatal("websearch server not found in DefaultServers") } + +func TestDefaultServersFallbacksWhenNoSkillsDir(t *testing.T) { + // Force UserHomeDir to fail so skillsDirOrDefault returns empty and both + // py and goNative fall back to the binary-on-PATH command. + orig := userHomeDirHook + userHomeDirHook = func() (string, error) { return "", errors.New("no home") } + t.Cleanup(func() { userHomeDirHook = orig }) + t.Setenv("SIN_SKILLS_DIR", "") + + for _, s := range DefaultServers() { + switch s.Name { + case "websearch": + if s.Command != "sin-websearch" { + t.Fatalf("websearch fallback command mismatch: %q", s.Command) + } + case "scheduler": + if s.Command != "sin-scheduler" { + t.Fatalf("scheduler fallback command mismatch: %q", s.Command) + } + } + } +} + +func TestDefaultServersPythonSkillWithSkillsDir(t *testing.T) { + dir := t.TempDir() + t.Setenv("SIN_SKILLS_DIR", dir) + + for _, s := range DefaultServers() { + if s.Name != "scheduler" { + continue + } + want := filepath.Join(dir, "SIN-Code-Scheduler-Skill", "mcp_server.py") + if s.Command != "python3" || len(s.Args) != 1 || s.Args[0] != want { + t.Fatalf("scheduler should use python3 + skills dir, got %+v", s) + } + return + } + t.Fatal("scheduler server not found in DefaultServers") +} + +func TestShortNameDefaultReturnsRepo(t *testing.T) { + if got := shortName("Unknown-Repo-Name"); got != "Unknown-Repo-Name" { + t.Fatalf("expected repo name unchanged, got %q", got) + } +} diff --git a/cmd/sin-code/internal/memory/memory_more_test.go b/cmd/sin-code/internal/memory/memory_more_test.go index 42537524..8a63a927 100644 --- a/cmd/sin-code/internal/memory/memory_more_test.go +++ b/cmd/sin-code/internal/memory/memory_more_test.go @@ -1,5 +1,3 @@ -//go:build coverage - // SPDX-License-Identifier: MIT // Purpose: targeted coverage tests for the memory package. These hit the // error branches and edge cases that require package-level hooks. diff --git a/cmd/sin-code/internal/permission/permission_test.go b/cmd/sin-code/internal/permission/permission_test.go index 269eba2a..558dd863 100644 --- a/cmd/sin-code/internal/permission/permission_test.go +++ b/cmd/sin-code/internal/permission/permission_test.go @@ -61,3 +61,20 @@ func TestFirstMatchWins(t *testing.T) { t.Error("first match (sin_*) should win over later sin_bash deny") } } + +func TestPolicyString(t *testing.T) { + cases := []struct { + p Policy + want string + }{ + {Allow, "allow"}, + {Ask, "ask"}, + {Deny, "deny"}, + {Policy(99), "deny"}, + } + for _, c := range cases { + if got := c.p.String(); got != c.want { + t.Errorf("%v.String() = %q, want %q", c.p, got, c.want) + } + } +} diff --git a/cmd/sin-code/internal/resource/limits_test.go b/cmd/sin-code/internal/resource/limits_test.go index c465291f..e594ca2b 100644 --- a/cmd/sin-code/internal/resource/limits_test.go +++ b/cmd/sin-code/internal/resource/limits_test.go @@ -3,6 +3,7 @@ package resource import ( "os" + "runtime" "runtime/debug" "testing" ) @@ -122,3 +123,58 @@ func TestDiskFreeOnTempDir(t *testing.T) { t.Errorf("DiskFree returned ok with non-positive free=%d", free) } } + +func TestDiskFreeError(t *testing.T) { + free, ok := DiskFree("/path/that/does/not/exist/ever") + if ok { + t.Error("DiskFree ok = true for non-existent path, want false") + } + if free != 0 { + t.Errorf("DiskFree free = %d, want 0", free) + } +} + +func TestLimitsApplyProcs(t *testing.T) { + prev := runtime.GOMAXPROCS(0) + defer runtime.GOMAXPROCS(prev) + + l := Limits{MaxProcs: 2} + l.Apply() + if got := runtime.GOMAXPROCS(0); got != 2 { + t.Errorf("GOMAXPROCS = %d, want 2", got) + } +} + +func TestLimitsApplyAll(t *testing.T) { + orig := debug.SetMemoryLimit(-1) + defer debug.SetMemoryLimit(orig) + prev := runtime.GOMAXPROCS(0) + defer runtime.GOMAXPROCS(prev) + + l := Limits{MaxMemoryBytes: 256 * 1024 * 1024, MaxProcs: 2} + l.Apply() + if got := debug.SetMemoryLimit(-1); got != l.MaxMemoryBytes { + t.Errorf("memory limit = %d, want %d", got, l.MaxMemoryBytes) + } + if got := runtime.GOMAXPROCS(0); got != 2 { + t.Errorf("GOMAXPROCS = %d, want 2", got) + } +} + +func TestParseLimitsBadMinDisk(t *testing.T) { + if _, err := ParseLimits("", 0, "bad"); err == nil { + t.Error("expected error for bad min-disk") + } +} + +func TestParseBytesNegativePlain(t *testing.T) { + if _, err := ParseBytes("-10"); err == nil { + t.Error("expected error for negative plain bytes") + } +} + +func TestParseBytesInvalidFloat(t *testing.T) { + if _, err := ParseBytes("1.2.3GiB"); err == nil { + t.Error("expected error for invalid float in suffixed size") + } +} diff --git a/cmd/sin-code/internal/sandbox/sandbox_test.go b/cmd/sin-code/internal/sandbox/sandbox_test.go index 1c0b3245..6c33a1c1 100644 --- a/cmd/sin-code/internal/sandbox/sandbox_test.go +++ b/cmd/sin-code/internal/sandbox/sandbox_test.go @@ -5,6 +5,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "time" ) @@ -78,3 +79,13 @@ func TestExisting_FiltersMissingPaths(t *testing.T) { t.Errorf("expected %q, got %q", present, got[0]) } } + +func TestApplyAndExec_NonLinux_ReturnsError(t *testing.T) { + err := ApplyAndExec() + if err == nil { + t.Fatal("ApplyAndExec() = nil, want error") + } + if !strings.Contains(err.Error(), "non-Linux") { + t.Errorf("ApplyAndExec() err = %q, want non-Linux error", err.Error()) + } +} diff --git a/cmd/sin-code/internal/security/sca/gomod.go b/cmd/sin-code/internal/security/sca/gomod.go index 14636c5b..68052d7c 100644 --- a/cmd/sin-code/internal/security/sca/gomod.go +++ b/cmd/sin-code/internal/security/sca/gomod.go @@ -14,6 +14,10 @@ import ( // readFile is swappable for tests that exercise ParseGoMod. var readFile = os.ReadFile +// modfileParse is swappable for tests that exercise the defensive +// nil/empty require branch. +var modfileParse = modfile.Parse + // ParseGoMod reads go.mod in projectPath and returns direct and indirect // module dependencies as Go-ecosystem packages. func ParseGoMod(projectPath string) ([]Package, error) { @@ -27,7 +31,7 @@ func ParseGoMod(projectPath string) ([]Package, error) { // parseGoModBytes parses go.mod content using golang.org/x/mod/modfile. func parseGoModBytes(data []byte) ([]Package, error) { - f, err := modfile.Parse("go.mod", data, nil) + f, err := modfileParse("go.mod", data, nil) if err != nil { return nil, fmt.Errorf("parse go.mod: %w", err) } diff --git a/cmd/sin-code/internal/security/sca/sca.go b/cmd/sin-code/internal/security/sca/sca.go index b924768c..1cd48651 100644 --- a/cmd/sin-code/internal/security/sca/sca.go +++ b/cmd/sin-code/internal/security/sca/sca.go @@ -11,6 +11,9 @@ import ( "path/filepath" ) +// resolvePath is swappable for tests that exercise the error path. +var resolvePath = filepath.Abs + // Package represents a parsed dependency. type Package struct { Name string `json:"name"` @@ -68,7 +71,7 @@ func DetectGoProject(path string) bool { // grype to obtain vulnerability findings. If grype is not available, the result // still contains the parsed dependency list with zero vulnerabilities. func (s *Scanner) Scan(ctx context.Context, path string) (*Result, error) { - path, err := filepath.Abs(path) + path, err := resolvePath(path) if err != nil { return nil, fmt.Errorf("resolve path: %w", err) } @@ -105,7 +108,7 @@ func (s *Scanner) Scan(ctx context.Context, path string) (*Result, error) { // ScanPackages returns the dependency list for a Go project without running grype. func (s *Scanner) ScanPackages(ctx context.Context, path string) ([]Package, error) { _ = ctx - path, err := filepath.Abs(path) + path, err := resolvePath(path) if err != nil { return nil, fmt.Errorf("resolve path: %w", err) } diff --git a/cmd/sin-code/internal/security/sca/sca_test.go b/cmd/sin-code/internal/security/sca/sca_test.go index 81f473cb..a1f2d9fe 100644 --- a/cmd/sin-code/internal/security/sca/sca_test.go +++ b/cmd/sin-code/internal/security/sca/sca_test.go @@ -4,11 +4,15 @@ package sca import ( "context" + "errors" "fmt" "os" "os/exec" "path/filepath" "testing" + + "golang.org/x/mod/modfile" + "golang.org/x/mod/module" ) func TestIsGoProject(t *testing.T) { @@ -406,6 +410,125 @@ printf '%s' '{ return &GrypeClient{Path: script, CommandRunner: exec.CommandContext} } +func TestParseGoModBytes_NilAndEmptyRequire(t *testing.T) { + old := modfileParse + defer func() { modfileParse = old }() + + modfileParse = func(name string, data []byte, fix modfile.VersionFixer) (*modfile.File, error) { + f, err := modfile.Parse(name, data, fix) + if err != nil { + return nil, err + } + f.Require = append(f.Require, nil) + f.Require = append(f.Require, &modfile.Require{Mod: module.Version{Path: "", Version: "v1.0.0"}}) + return f, nil + } + data := []byte(`module x + +go 1.23 +require github.com/a/b v1.0.0 +`) + pkgs, err := parseGoModBytes(data) + if err != nil { + t.Fatal(err) + } + if len(pkgs) != 1 || pkgs[0].Name != "github.com/a/b" { + t.Fatalf("expected only the valid require, got %+v", pkgs) + } +} + +func TestGrypeClientAvailable_EmptyPath(t *testing.T) { + c := &GrypeClient{} + // We cannot assert the boolean value without controlling PATH, but the + // empty-path branch must be exercised without panicking. + _ = c.Available() +} + +func TestGrypeClientScanDirectory_NilRunner(t *testing.T) { + client := &GrypeClient{Path: "false", CommandRunner: nil} + _, err := client.ScanDirectory(context.Background(), t.TempDir()) + if err == nil { + t.Fatal("expected error when command fails") + } +} + +func TestScannerScan_ResolvePathError(t *testing.T) { + old := resolvePath + resolvePath = func(path string) (string, error) { return "", errors.New("boom") } + defer func() { resolvePath = old }() + + _, err := New().Scan(context.Background(), t.TempDir()) + if err == nil { + t.Fatal("expected error from resolve path") + } +} + +func TestGrypeClientScanDirectory_EmptyPath(t *testing.T) { + client := &GrypeClient{ + Path: "", + CommandRunner: func(ctx context.Context, name string, arg ...string) *exec.Cmd { return exec.CommandContext(ctx, "false") }, + } + _, err := client.ScanDirectory(context.Background(), t.TempDir()) + if err == nil { + t.Fatal("expected error when command fails") + } +} + +func TestScannerScan_MalformedGoMod(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("not a go.mod file"), 0644); err != nil { + t.Fatal(err) + } + _, err := New().Scan(context.Background(), dir) + if err == nil { + t.Fatal("expected error for malformed go.mod") + } +} + +func TestScannerScan_GrypeErrorSwallowed(t *testing.T) { + old := resolvePath + resolvePath = func(path string) (string, error) { return filepath.Abs(path) } + defer func() { resolvePath = old }() + + dir := t.TempDir() + goMod := filepath.Join(dir, "go.mod") + if err := os.WriteFile(goMod, []byte("module example.com/test\n\nrequire github.com/foo/bar v1.2.3\n"), 0644); err != nil { + t.Fatal(err) + } + + client := &GrypeClient{ + Path: "true", // Available() returns true + CommandRunner: func(ctx context.Context, name string, arg ...string) *exec.Cmd { + return exec.CommandContext(ctx, "false") + }, + } + res, err := NewWithGrype(client).Scan(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + if len(res.Vulnerabilities) != 0 { + t.Fatalf("expected 0 vulns when grype errors, got %d", len(res.Vulnerabilities)) + } +} + +func TestScannerScanPackages_ResolvePathError(t *testing.T) { + old := resolvePath + resolvePath = func(path string) (string, error) { return "", errors.New("boom") } + defer func() { resolvePath = old }() + + _, err := New().ScanPackages(context.Background(), t.TempDir()) + if err == nil { + t.Fatal("expected error from resolve path") + } +} + +func TestScannerScanPackages_NonGoProject(t *testing.T) { + _, err := New().ScanPackages(context.Background(), t.TempDir()) + if err == nil { + t.Fatal("expected error for non-Go project") + } +} + // BenchmarkParseGoModBytes gives a rough sense of parser performance. func BenchmarkParseGoModBytes(b *testing.B) { data := []byte(fmt.Sprintf(`module example.com/bench diff --git a/cmd/sin-code/internal/stopgate/stopgate_test.go b/cmd/sin-code/internal/stopgate/stopgate_test.go index c14a1741..e060eb70 100644 --- a/cmd/sin-code/internal/stopgate/stopgate_test.go +++ b/cmd/sin-code/internal/stopgate/stopgate_test.go @@ -10,6 +10,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/agentloop" @@ -166,6 +167,68 @@ func TestLoopGateAdapter(t *testing.T) { } } +// failedCheckNames must fall back to the Kind string when Name is empty. +func TestFailedCheckNamesFallbackToKind(t *testing.T) { + g := New(t.TempDir()) + c := goalcontract.GoalContract{DeterministicChecks: []orchestrator.Check{ + {Kind: orchestrator.CheckBuild, Cmd: []string{"false"}}, + }} + dec := g.Evaluate(context.Background(), c, snap()) + if dec.Complete { + t.Fatal("expected failure") + } + found := false + for _, o := range dec.OpenCriteria { + if strings.Contains(o, "CheckBuild") || strings.Contains(o, "build") { + found = true + } + } + if !found { + t.Fatalf("expected open criteria to name the check, got %v", dec.OpenCriteria) + } +} + +func TestJudgeOpenCriteriaDefaultMessage(t *testing.T) { + j := &fakeJudge{result: &eval.JudgeResult{Pass: false, Score: 0.0, Reason: "", Feedback: ""}} + g := New(t.TempDir(), WithJudge(j)) + c := goalcontract.GoalContract{ + DeterministicChecks: []orchestrator.Check{passPredicate("p")}, + SemanticCriteria: []string{"docs updated"}, + } + dec := g.Evaluate(context.Background(), c, snap()) + if dec.Complete { + t.Fatal("judge reject should block completion") + } + if len(dec.OpenCriteria) == 0 { + t.Fatal("expected open criteria") + } + if dec.OpenCriteria[0] != "semantic acceptance criteria not met" { + t.Fatalf("expected default message, got %q", dec.OpenCriteria[0]) + } +} + +func TestJudgeOpenCriteriaFeedback(t *testing.T) { + j := &fakeJudge{result: &eval.JudgeResult{Pass: false, Score: 0.0, Reason: "", Feedback: "add more tests"}} + g := New(t.TempDir(), WithJudge(j)) + c := goalcontract.GoalContract{ + DeterministicChecks: []orchestrator.Check{passPredicate("p")}, + SemanticCriteria: []string{"docs updated"}, + } + dec := g.Evaluate(context.Background(), c, snap()) + if dec.Complete { + t.Fatal("judge reject should block completion") + } + found := false + for _, o := range dec.OpenCriteria { + if strings.Contains(o, "add more tests") { + found = true + } + } + if !found { + t.Fatalf("expected feedback in open criteria, got %v", dec.OpenCriteria) + } +} + // Sanity: a real-ish predicate against the filesystem. func TestDoneWhenAgainstFile(t *testing.T) { ws := t.TempDir() diff --git a/cmd/sin-code/internal/style/style_test.go b/cmd/sin-code/internal/style/style_test.go index d2effff0..8f2d1948 100644 --- a/cmd/sin-code/internal/style/style_test.go +++ b/cmd/sin-code/internal/style/style_test.go @@ -102,6 +102,15 @@ func TestAllModes_ReturnsAllCanonical(t *testing.T) { } } +func TestRulesForDefaultReturnsEmpty(t *testing.T) { + t.Parallel() + for _, m := range []Mode{ModeDefault, ModeVerbose, Mode("loud")} { + if got := rulesFor(m); got != "" { + t.Errorf("rulesFor(%q) should be empty, got %q", m, got) + } + } +} + func TestMode_String(t *testing.T) { t.Parallel() if (Mode("")).String() != string(ModeDefault) { diff --git a/cmd/sin-code/internal/summary/summary_test.go b/cmd/sin-code/internal/summary/summary_test.go index 69d4250b..29629bdc 100644 --- a/cmd/sin-code/internal/summary/summary_test.go +++ b/cmd/sin-code/internal/summary/summary_test.go @@ -6,6 +6,7 @@ package summary import ( "context" "path/filepath" + "strings" "testing" "time" @@ -74,6 +75,152 @@ func TestBuildSummaryNoEntries(t *testing.T) { } } +func TestBuildSummaryStoreError(t *testing.T) { + ctx := context.Background() + // Close the ledger immediately so List returns an error. + s, err := ledger.Open(filepath.Join(t.TempDir(), "ledger.db")) + if err != nil { + t.Fatal(err) + } + _ = s.Close() + _, err = Build(ctx, s, "x") + if err == nil { + t.Fatal("expected error from closed ledger") + } +} + +func TestBuildFromEntriesEarlierCreatedAt(t *testing.T) { + base := time.Now() + entries := []ledger.Entry{ + {SessionID: "s1", Type: ledger.TypeUserPrompt, Data: map[string]any{"content": "x"}, CreatedAt: base.Add(time.Minute)}, + {SessionID: "s1", Type: ledger.TypeToolCall, Data: map[string]any{"tool": "a"}, CreatedAt: base.Add(-time.Minute)}, + } + s, err := buildFromEntries(entries) + if err != nil { + t.Fatal(err) + } + if !s.CreatedAt.Equal(base.Add(-time.Minute)) { + t.Fatalf("CreatedAt should be the earliest entry, got %s", s.CreatedAt.Format(time.RFC3339)) + } +} + +func TestBuildFromEntriesEmptyUserPrompt(t *testing.T) { + entries := []ledger.Entry{ + {SessionID: "s1", Type: ledger.TypeUserPrompt, Data: map[string]any{"content": ""}, CreatedAt: time.Now()}, + } + s, err := buildFromEntries(entries) + if err != nil { + t.Fatal(err) + } + if len(s.UserPrompts) != 0 { + t.Fatalf("expected empty user prompts, got %v", s.UserPrompts) + } +} + +func TestBuildFromEntriesNoToolName(t *testing.T) { + entries := []ledger.Entry{ + {SessionID: "s1", Type: ledger.TypeToolCall, Data: map[string]any{}, CreatedAt: time.Now()}, + } + s, err := buildFromEntries(entries) + if err != nil { + t.Fatal(err) + } + if s.Turns != 1 { + t.Fatalf("expected 1 turn, got %d", s.Turns) + } + if len(s.ToolsUsed) != 0 { + t.Fatalf("expected no tool name, got %v", s.ToolsUsed) + } +} + +func TestBuildFromEntriesVerifyFail(t *testing.T) { + entries := []ledger.Entry{ + {SessionID: "s1", Type: ledger.TypeVerifyFail, Data: map[string]any{"mode": "poc"}, CreatedAt: time.Now()}, + } + s, err := buildFromEntries(entries) + if err != nil { + t.Fatal(err) + } + if s.Verified { + t.Fatal("expected not verified") + } + if s.Verification != "poc (failed)" { + t.Fatalf("expected verification failure text, got %q", s.Verification) + } +} + +func TestBuildFromEntriesVerifyFailNoMode(t *testing.T) { + entries := []ledger.Entry{ + {SessionID: "s1", Type: ledger.TypeVerifyFail, Data: map[string]any{}, CreatedAt: time.Now()}, + } + s, err := buildFromEntries(entries) + if err != nil { + t.Fatal(err) + } + if s.Verification != "not verified" { + t.Fatalf("expected not verified, got %q", s.Verification) + } +} + +func TestBuildFromEntriesVerifyPassNoMode(t *testing.T) { + entries := []ledger.Entry{ + {SessionID: "s1", Type: ledger.TypeVerifyPass, Data: map[string]any{}, CreatedAt: time.Now()}, + } + s, err := buildFromEntries(entries) + if err != nil { + t.Fatal(err) + } + if !s.Verified { + t.Fatal("expected verified") + } + if s.Verification != "unknown mode" { + t.Fatalf("expected unknown mode, got %q", s.Verification) + } +} + +func TestBuildFromEntriesTaskCompleteNoSummary(t *testing.T) { + entries := []ledger.Entry{ + {SessionID: "s1", Type: ledger.TypeTaskComplete, Data: map[string]any{}, CreatedAt: time.Now()}, + } + s, err := buildFromEntries(entries) + if err != nil { + t.Fatal(err) + } + if s.OneLiner != "" { + t.Fatalf("expected empty one-liner, got %q", s.OneLiner) + } +} + +func TestBuildFromEntriesLongPromptBecomesOneLiner(t *testing.T) { + long := strings.Repeat("a", 90) + entries := []ledger.Entry{ + {SessionID: "s1", Type: ledger.TypeUserPrompt, Data: map[string]any{"content": long}, CreatedAt: time.Now()}, + } + s, err := buildFromEntries(entries) + if err != nil { + t.Fatal(err) + } + want := long[:80] + "…" + if s.OneLiner != want { + t.Fatalf("expected truncated one-liner, got %q", s.OneLiner) + } +} + +func TestBuildFromEntriesLongPromptDoesNotOverwriteSummary(t *testing.T) { + long := strings.Repeat("b", 90) + entries := []ledger.Entry{ + {SessionID: "s1", Type: ledger.TypeUserPrompt, Data: map[string]any{"content": long}, CreatedAt: time.Now()}, + {SessionID: "s1", Type: ledger.TypeTaskComplete, Data: map[string]any{"summary": "done"}, CreatedAt: time.Now()}, + } + s, err := buildFromEntries(entries) + if err != nil { + t.Fatal(err) + } + if s.OneLiner != "done" { + t.Fatalf("expected one-liner from task complete, got %q", s.OneLiner) + } +} + func TestFormat(t *testing.T) { s := &Summary{ SessionID: "fmt-1", diff --git a/cmd/sin-code/internal/summary/summary_tokens_test.go b/cmd/sin-code/internal/summary/summary_tokens_test.go index 60daa6ae..4b3b488f 100644 --- a/cmd/sin-code/internal/summary/summary_tokens_test.go +++ b/cmd/sin-code/internal/summary/summary_tokens_test.go @@ -5,8 +5,12 @@ package summary import ( "context" "errors" + "path/filepath" "strings" "testing" + "time" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/ledger" ) type fakeTokenSrc struct { @@ -102,12 +106,84 @@ func TestHumanInt(t *testing.T) { // BuildWithTokens swallows errors from the token source (best-effort). Make // sure a broken source still produces a Summary keyed off Ledger entries. func TestBuildWithTokensSwallowsSrcError(t *testing.T) { - // Use a stub that always errors. Build path doesn't use ledger here; we - // only check the contract by calling BuildWithTokens with a nil store, - // which returns an error – so we instead verify the contract directly: + s := testStore(t) + ctx := context.Background() + sid := "tokens-err" + if _, err := s.Record(ctx, ledger.Entry{SessionID: sid, Type: ledger.TypeUserPrompt, Data: map[string]any{"content": "x"}, CreatedAt: time.Now()}); err != nil { + t.Fatal(err) + } src := &fakeTokenSrc{err: errors.New("disk full")} - _, _, _, _, _, err := src.SessionTokens(context.Background(), "x") + sum, err := BuildWithTokens(ctx, s, sid, src) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sum.HasUsage { + t.Fatal("expected no usage when token source errors") + } +} + +func TestBuildWithTokensNilSrc(t *testing.T) { + s := testStore(t) + ctx := context.Background() + sid := "tokens-nil" + if _, err := s.Record(ctx, ledger.Entry{SessionID: sid, Type: ledger.TypeUserPrompt, Data: map[string]any{"content": "x"}, CreatedAt: time.Now()}); err != nil { + t.Fatal(err) + } + sum, err := BuildWithTokens(ctx, s, sid, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sum.HasUsage { + t.Fatal("expected no usage with nil token source") + } +} + +func TestBuildWithTokensFillsFromSource(t *testing.T) { + s := testStore(t) + ctx := context.Background() + sid := "tokens-ok" + if _, err := s.Record(ctx, ledger.Entry{SessionID: sid, Type: ledger.TypeUserPrompt, Data: map[string]any{"content": "x"}, CreatedAt: time.Now()}); err != nil { + t.Fatal(err) + } + src := &fakeTokenSrc{in: 10, out: 20, total: 30, events: 1, cost: 0.0012} + sum, err := BuildWithTokens(ctx, s, sid, src) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sum.InputTokens != 10 || sum.OutputTokens != 20 || sum.TokensUsed != 30 || sum.TokenCount != 1 || sum.CostUSD != 0.0012 { + t.Fatalf("unexpected token fields: %+v", sum) + } + if !sum.HasUsage { + t.Fatal("expected HasUsage true") + } +} + +func TestBuildWithTokensZeroTotalLeavesHasUsageFalse(t *testing.T) { + s := testStore(t) + ctx := context.Background() + sid := "tokens-zero" + if _, err := s.Record(ctx, ledger.Entry{SessionID: sid, Type: ledger.TypeUserPrompt, Data: map[string]any{"content": "x"}, CreatedAt: time.Now()}); err != nil { + t.Fatal(err) + } + src := &fakeTokenSrc{in: 0, out: 0, total: 0, events: 0, cost: 0} + sum, err := BuildWithTokens(ctx, s, sid, src) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sum.HasUsage { + t.Fatal("expected HasUsage false when total is zero") + } +} + +func TestBuildWithTokensBuildError(t *testing.T) { + ctx := context.Background() + s, err := ledger.Open(filepath.Join(t.TempDir(), "ledger.db")) + if err != nil { + t.Fatal(err) + } + _ = s.Close() + _, err = BuildWithTokens(ctx, s, "x", &fakeTokenSrc{}) if err == nil { - t.Fatal("expected error from fake") + t.Fatal("expected error from Build path") } } diff --git a/cmd/sin-code/internal/testutil/testutil.go b/cmd/sin-code/internal/testutil/testutil.go index 0b55e7b3..fae40b44 100644 --- a/cmd/sin-code/internal/testutil/testutil.go +++ b/cmd/sin-code/internal/testutil/testutil.go @@ -28,6 +28,11 @@ import ( _ "modernc.org/sqlite" // pure-Go SQLite, M2 (CGO_ENABLED=0) ) +// sqlOpen and dbPing are package-level hooks so tests can exercise the +// error paths of IsolatedSQLite without mocking the database/sql driver. +var sqlOpen = sql.Open +var dbPing = func(db *sql.DB) error { return db.Ping() } + // IsolatedSQLite returns a *sql.DB opened in t.TempDir(), automatically // closed at test end via t.Cleanup. The DB is created in a fresh // temp directory per call, so concurrent tests cannot share state. @@ -39,11 +44,11 @@ func IsolatedSQLite(t *testing.T) *sql.DB { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "test.db") - db, err := sql.Open("sqlite", path) + db, err := sqlOpen("sqlite", path) if err != nil { t.Fatalf("IsolatedSQLite: open: %v", err) } - if err := db.Ping(); err != nil { + if err := dbPing(db); err != nil { t.Fatalf("IsolatedSQLite: ping: %v", err) } t.Cleanup(func() { _ = db.Close() }) @@ -57,11 +62,13 @@ func IsolatedSQLite(t *testing.T) *sql.DB { // t.Cleanup(func() { os.Setenv(k, prev) }) // // pattern that is easy to get wrong (esp. when the prev value is ""). +var setenv = os.Setenv + func CleanEnv(t *testing.T, kv map[string]string) { t.Helper() for k, v := range kv { prev, had := os.LookupEnv(k) - if err := os.Setenv(k, v); err != nil { + if err := setenv(k, v); err != nil { t.Fatalf("CleanEnv: setenv %s: %v", k, err) } k, v, prev, had := k, v, prev, had // capture diff --git a/cmd/sin-code/internal/testutil/testutil_test.go b/cmd/sin-code/internal/testutil/testutil_test.go index ef076807..bc997d84 100644 --- a/cmd/sin-code/internal/testutil/testutil_test.go +++ b/cmd/sin-code/internal/testutil/testutil_test.go @@ -67,6 +67,38 @@ func TestIsolatedSQLite_ClosesOnCleanup(t *testing.T) { _ = db2 } +func TestIsolatedSQLite_OpenError(t *testing.T) { + prev := sqlOpen + sqlOpen = func(driverName, dataSourceName string) (*sql.DB, error) { + return nil, errors.New("open failed") + } + defer func() { sqlOpen = prev }() + done := make(chan bool) + go func() { + ft := &testing.T{} + defer func() { done <- ft.Failed() }() + _ = IsolatedSQLite(ft) + }() + if failed := <-done; !failed { + t.Error("expected failure when sqlOpen errors") + } +} + +func TestIsolatedSQLite_PingError(t *testing.T) { + prev := dbPing + dbPing = func(db *sql.DB) error { return errors.New("ping failed") } + defer func() { dbPing = prev }() + done := make(chan bool) + go func() { + ft := &testing.T{} + defer func() { done <- ft.Failed() }() + _ = IsolatedSQLite(ft) + }() + if failed := <-done; !failed { + t.Error("expected failure when dbPing errors") + } +} + // ── CleanEnv ────────────────────────────────────────────────────────── func TestCleanEnv_SetsAndRestores(t *testing.T) { @@ -108,6 +140,21 @@ func TestCleanEnv_HandlesEmptyPrevious(t *testing.T) { } } +func TestCleanEnv_SetenvError(t *testing.T) { + prev := setenv + setenv = func(k, v string) error { return errors.New("setenv failed") } + defer func() { setenv = prev }() + done := make(chan bool) + go func() { + ft := &testing.T{} + defer func() { done <- ft.Failed() }() + CleanEnv(ft, map[string]string{"_TESTUTIL_X": "y"}) + }() + if failed := <-done; !failed { + t.Error("expected failure when setenv errors") + } +} + // ── WithTimeout ─────────────────────────────────────────────────────── func TestWithTimeout_FastFn(t *testing.T) { @@ -172,6 +219,50 @@ func TestWithTimeout_PanicPropagates(t *testing.T) { }) } +func TestWithTimeout_PanicAfterDeadline(t *testing.T) { + // If fn panics *after* the context deadline has already fired, + // WithTimeout must still propagate the panic via the second + // select-case. + defer func() { + if r := recover(); r == nil { + t.Error("expected panic to propagate after deadline") + } + }() + WithTimeout(t, 1*time.Millisecond, func(_ context.Context) { + time.Sleep(20 * time.Millisecond) + panic("late panic") + }) +} + +func TestWithTimeout_FiresAndFails(t *testing.T) { + // The timeout branch is only reachable when fn does not return + // before the deadline. Use a fake T in a goroutine so the fatal + // exit does not kill the parent test. The fn is blocked on a stop + // channel so we can clean up the leaked goroutine after observing + // the failure. + done := make(chan bool) + stop := make(chan struct{}) + go func() { + ft := &testing.T{} + defer func() { done <- ft.Failed() }() + WithTimeout(ft, 1*time.Millisecond, func(_ context.Context) { + <-stop + }) + }() + go func() { + time.Sleep(100 * time.Millisecond) + close(stop) + }() + select { + case failed := <-done: + if !failed { + t.Error("expected WithTimeout to fail the fake T") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for WithTimeout result") + } +} + // ── GoroutineLeakCheck ──────────────────────────────────────────────── func TestGoroutineLeakCheck_NoLeak(t *testing.T) { diff --git a/cmd/sin-code/internal/triage/loader.go b/cmd/sin-code/internal/triage/loader.go index 25173b9a..e376a6ca 100644 --- a/cmd/sin-code/internal/triage/loader.go +++ b/cmd/sin-code/internal/triage/loader.go @@ -18,6 +18,11 @@ import ( // can inject a fixture without spawning gh. var Loader = loadFromGH +// ghExec is a package-level hook for the ghbridge call so the +// loadFromGH error and parsing paths can be exercised without a live +// `gh` binary. +var ghExec = ghbridge.New().Execute + // loadFromGH runs `gh issue list --state open --json ...` via the // bridge. The JSON fields are deliberately a strict subset of what // gh can return — see the Issue struct for what we need. @@ -32,7 +37,7 @@ func loadFromGH(ctx context.Context, repo string) ([]Issue, error) { if repo != "" { args = append(args, "--repo", repo) } - out, _, err := ghbridge.New().Execute(ctx, args) + out, _, err := ghExec(ctx, args) if err != nil { return nil, fmt.Errorf("gh issue list: %w", err) } diff --git a/cmd/sin-code/internal/triage/triage_test.go b/cmd/sin-code/internal/triage/triage_test.go index 52515603..19c9ec29 100644 --- a/cmd/sin-code/internal/triage/triage_test.go +++ b/cmd/sin-code/internal/triage/triage_test.go @@ -5,10 +5,14 @@ package triage import ( "bytes" + "context" "encoding/json" + "fmt" "strings" "testing" "time" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/ghbridge" ) func mkIssue(num int, title string, labels []string, body string, updatedDaysAgo int) Issue { @@ -235,3 +239,235 @@ func TestUpdated_FallsBackToZero(t *testing.T) { t.Error("expected zero time for garbage UpdatedAt") } } + +func TestCreated_FallsBackToZero(t *testing.T) { + i := Issue{} + if !i.Created().IsZero() { + t.Error("expected zero time for empty CreatedAt") + } + i.CreatedAt = "garbage" + if !i.Created().IsZero() { + t.Error("expected zero time for garbage CreatedAt") + } +} + +func TestCreated_ParsesValid(t *testing.T) { + i := Issue{CreatedAt: "2026-06-16T12:00:00Z"} + got := i.Created() + want := time.Date(2026, 6, 16, 12, 0, 0, 0, time.UTC) + if !got.Equal(want) { + t.Errorf("created: got %v, want %v", got, want) + } +} + +func TestGroupInOrder_Empty(t *testing.T) { + if got := groupInOrder(ScoredList{}); got != nil { + t.Errorf("expected nil for empty list, got %v", got) + } +} + +func TestGroupInOrder_SingleItem(t *testing.T) { + list := ScoredList{Items: []Scored{{GroupKey: "epic"}}} + got := groupInOrder(list) + if len(got) != 1 || got[0].key != "epic" { + t.Errorf("expected one epic group, got %v", got) + } +} + +func TestGroupInOrder_SortByTopScore(t *testing.T) { + list := ScoredList{Items: []Scored{ + {GroupKey: "low", Score: 1}, + {GroupKey: "high", Score: 10}, + }} + got := groupInOrder(list) + if len(got) != 2 { + t.Fatalf("expected 2 groups, got %d", len(got)) + } + if got[0].key != "high" || got[1].key != "low" { + t.Errorf("expected high before low, got %v", got) + } +} + +func TestTrim_EdgeCases(t *testing.T) { + cases := []struct { + s string + n int + want string + }{ + {"", 5, ""}, + {"hi", 5, "hi"}, + {"hello", 4, "hel…"}, + {"x", 1, "x"}, + {"ab", 1, "…"}, + } + for _, c := range cases { + if got := trim(c.s, c.n); got != c.want { + t.Errorf("trim(%q, %d) = %q, want %q", c.s, c.n, got, c.want) + } + } +} + +func TestScore_GoodFirstIssue(t *testing.T) { + now := fixedNow() + base := mkIssue(1, "base", nil, "", 1) + gf := mkIssue(2, "gf", []string{"good first issue"}, "", 1) + baseScore := Score(base, now, []Issue{base, gf}) + gfScore := Score(gf, now, []Issue{base, gf}) + if gfScore.Score >= baseScore.Score { + t.Errorf("good first issue should lower score relative to base (%d vs %d)", gfScore.Score, baseScore.Score) + } + if !strings.Contains(strings.Join(gfScore.Reasons, " "), "good first issue") { + t.Errorf("expected good-first-issue reason, got %v", gfScore.Reasons) + } +} + +func TestScore_FusionLabel(t *testing.T) { + now := fixedNow() + i := mkIssue(1, "f", []string{"fusion"}, "", 1) + s := Score(i, now, []Issue{i}) + if !strings.Contains(strings.Join(s.Reasons, " "), "fusion") { + t.Errorf("expected fusion reason, got %v", s.Reasons) + } +} + +func TestScore_MemoryLabel(t *testing.T) { + now := fixedNow() + i := mkIssue(1, "m", []string{"memory"}, "", 1) + s := Score(i, now, []Issue{i}) + if !strings.Contains(strings.Join(s.Reasons, " "), "memory/v0") { + t.Errorf("expected memory/v0 reason, got %v", s.Reasons) + } +} + +func TestScore_V0Label(t *testing.T) { + now := fixedNow() + i := mkIssue(1, "v", []string{"v0"}, "", 1) + s := Score(i, now, []Issue{i}) + if strings.Contains(strings.Join(s.Reasons, " "), "not in v0") { + t.Errorf("v0 label should suppress the not-in-v0 reason, got %v", s.Reasons) + } + if !strings.Contains(strings.Join(s.Reasons, " "), "memory/v0") { + t.Errorf("expected memory/v0 reason, got %v", s.Reasons) + } +} + +func TestScoreAll_TieBreakByNumber(t *testing.T) { + now := fixedNow() + a := mkIssue(1, "a", nil, "", 1) + b := mkIssue(2, "b", nil, "", 1) + list := ScoreAll([]Issue{a, b}, now) + if list.Items[0].Issue.Number != 1 { + t.Errorf("expected #1 first on tie, got #%d", list.Items[0].Issue.Number) + } +} + +func TestAgeDays_Future(t *testing.T) { + now := fixedNow() + future := now.Add(24 * time.Hour) + if got := ageDays(future, now); got != 0 { + t.Errorf("future date should report 0 days, got %d", got) + } +} + +func TestAgeDays_Zero(t *testing.T) { + now := fixedNow() + if got := ageDays(time.Time{}, now); got != 1<<31-1 { + t.Errorf("zero time should report max days, got %d", got) + } +} + +func TestBlocksReason_Singular(t *testing.T) { + if got := blocksReason(1); got != "blocks 1 other issue" { + t.Errorf("blocksReason(1) = %q", got) + } +} + +func TestBlocksReason_Plural(t *testing.T) { + if got := blocksReason(2); got != "blocks 2 other issues" { + t.Errorf("blocksReason(2) = %q", got) + } +} + +func TestItoa(t *testing.T) { + cases := map[int]string{ + 0: "0", + 1: "1", + -42: "-42", + 1234: "1234", + } + for n, want := range cases { + if got := itoa(n); got != want { + t.Errorf("itoa(%d) = %q, want %q", n, got, want) + } + } +} + +func TestLoadFromGH_Success(t *testing.T) { + prev := ghExec + defer func() { ghExec = prev }() + ghExec = func(_ context.Context, args []string) (string, ghbridge.Tier, error) { + if !contains(args, "--repo") { + return "", 0, fmt.Errorf("expected --repo in args") + } + out := `[{"number":7,"title":"t","body":"b","state":"OPEN","author":{"login":"a"},"labels":[{"name":"bug"}],"updatedAt":"2026-06-16T10:00:00Z","createdAt":"2026-06-16T09:00:00Z","url":"https://example.com/7"}]` + return out, ghbridge.TierReadOnly, nil + } + issues, err := loadFromGH(context.Background(), "owner/repo") + if err != nil { + t.Fatal(err) + } + if len(issues) != 1 || issues[0].Number != 7 { + t.Errorf("expected 1 issue (#7), got %+v", issues) + } +} + +func TestLoadFromGH_NoRepo(t *testing.T) { + prev := ghExec + defer func() { ghExec = prev }() + ghExec = func(_ context.Context, args []string) (string, ghbridge.Tier, error) { + if contains(args, "--repo") { + return "", 0, fmt.Errorf("did not expect --repo in args") + } + return "[]", ghbridge.TierReadOnly, nil + } + issues, err := loadFromGH(context.Background(), "") + if err != nil { + t.Fatal(err) + } + if len(issues) != 0 { + t.Errorf("expected 0 issues, got %d", len(issues)) + } +} + +func TestLoadFromGH_Error(t *testing.T) { + prev := ghExec + defer func() { ghExec = prev }() + ghExec = func(_ context.Context, args []string) (string, ghbridge.Tier, error) { + return "", ghbridge.TierForbidden, fmt.Errorf("boom") + } + _, err := loadFromGH(context.Background(), "") + if err == nil { + t.Fatal("expected error") + } +} + +func TestLoadFromGH_ParseError(t *testing.T) { + prev := ghExec + defer func() { ghExec = prev }() + ghExec = func(_ context.Context, args []string) (string, ghbridge.Tier, error) { + return "not-json", ghbridge.TierReadOnly, nil + } + _, err := loadFromGH(context.Background(), "") + if err == nil { + t.Fatal("expected parse error") + } +} + +func contains(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} diff --git a/cmd/sin-code/internal/usage/store.go b/cmd/sin-code/internal/usage/store.go index c2001cc9..e6753999 100644 --- a/cmd/sin-code/internal/usage/store.go +++ b/cmd/sin-code/internal/usage/store.go @@ -66,6 +66,22 @@ type Store struct { pricingPer1K map[string]float64 } +// Package-level hooks for error paths that are otherwise impossible to +// trigger in a portable test (no real filesystem errors, missing driver, etc.). +var ( + userHomeDir = os.UserHomeDir + mkdirAll = os.MkdirAll + sqlOpen = sql.Open + migrateExec = func(db *sql.DB, schema string) error { _, err := db.Exec(schema); return err } + queryRows = func(ctx context.Context, db *sql.DB, query string, args ...any) (*sql.Rows, error) { + return db.QueryContext(ctx, query, args...) + } + rowsClose = func(r *sql.Rows) error { return r.Close() } + aggregateQuery = func(ctx context.Context, db *sql.DB, query string, args ...any) (*sql.Rows, error) { + return db.QueryContext(ctx, query, args...) + } +) + // DefaultPath returns the canonical on-disk location: // // $XDG_DATA_HOME/sin-code/tokens.db (else ~/.local/share/sin-code/tokens.db) @@ -81,7 +97,7 @@ func DefaultPath() string { } dir := os.Getenv("XDG_DATA_HOME") if dir == "" { - home, _ := os.UserHomeDir() + home, _ := userHomeDir() if home == "" { return "tokens.db" } @@ -96,10 +112,10 @@ func Open(path string) (*Store, error) { if path == "" { path = DefaultPath() } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if err := mkdirAll(filepath.Dir(path), 0o755); err != nil { return nil, fmt.Errorf("usage: mkdir: %w", err) } - db, err := sql.Open("sqlite", path) + db, err := sqlOpen("sqlite", path) if err != nil { return nil, err } @@ -161,8 +177,7 @@ CREATE INDEX IF NOT EXISTS idx_usage_source ON usage_events(source); CREATE INDEX IF NOT EXISTS idx_usage_created ON usage_events(created_at); PRAGMA user_version = 1; ` - _, err := s.db.Exec(schema) - return err + return migrateExec(s.db, schema) } // Record persists one event. SessionID/Model/Source default to safe @@ -335,7 +350,7 @@ FROM usage_events default: return top, nil, fmt.Errorf("usage: unknown group_by %q (want day|month|model|source|session)", groupBy) } - rows, err := s.db.QueryContext(ctx, subSQL, args...) + rows, err := aggregateQuery(ctx, s.db, subSQL, args...) if err != nil { return nil, nil, err } @@ -359,7 +374,7 @@ FROM usage_events } func scanBreakdowns(ctx context.Context, db *sql.DB, f Filter, where string, args []any, top *Aggregation) error { - rows, err := db.QueryContext(ctx, `SELECT model, SUM(total_tokens) FROM usage_events `+where+` GROUP BY model`, args...) + rows, err := queryRows(ctx, db, `SELECT model, SUM(total_tokens) FROM usage_events `+where+` GROUP BY model`, args...) if err != nil { return err } @@ -367,20 +382,20 @@ func scanBreakdowns(ctx context.Context, db *sql.DB, f Filter, where string, arg var m string var t int if err := rows.Scan(&m, &t); err != nil { - _ = rows.Close() + _ = rowsClose(rows) return err } top.ByModel[m] = t } - if err := rows.Close(); err != nil { + if err := rowsClose(rows); err != nil { return err } - rows, err = db.QueryContext(ctx, `SELECT source, SUM(total_tokens) FROM usage_events `+where+` GROUP BY source`, args...) + rows, err = queryRows(ctx, db, `SELECT source, SUM(total_tokens) FROM usage_events `+where+` GROUP BY source`, args...) if err != nil { return err } - defer rows.Close() + defer rowsClose(rows) for rows.Next() { var src string var t int diff --git a/cmd/sin-code/internal/usage/usage_test.go b/cmd/sin-code/internal/usage/usage_test.go index 3ea70ea2..8ef0fd6d 100644 --- a/cmd/sin-code/internal/usage/usage_test.go +++ b/cmd/sin-code/internal/usage/usage_test.go @@ -5,6 +5,7 @@ package usage import ( "context" + "database/sql" "encoding/json" "errors" "fmt" @@ -622,3 +623,364 @@ func absFloat(f float64) float64 { } return f } + +func TestDefaultPath_NoHome(t *testing.T) { + prev := userHomeDir + userHomeDir = func() (string, error) { return "", nil } + defer func() { userHomeDir = prev }() + t.Setenv("SIN_CODE_TOKENS_DB", "") + t.Setenv("XDG_DATA_HOME", "") + if got := DefaultPath(); got != "tokens.db" { + t.Errorf("expected fallback tokens.db, got %q", got) + } +} + +func TestOpen_MkdirError(t *testing.T) { + prev := mkdirAll + mkdirAll = func(path string, perm os.FileMode) error { return fmt.Errorf("mkdir denied") } + defer func() { mkdirAll = prev }() + _, err := Open("/some/path/tokens.db") + if err == nil { + t.Fatal("expected error") + } +} + +func TestOpen_SqlOpenError(t *testing.T) { + prev := sqlOpen + sqlOpen = func(driverName, dataSourceName string) (*sql.DB, error) { + return nil, fmt.Errorf("driver broken") + } + defer func() { sqlOpen = prev }() + _, err := Open(t.TempDir() + "/tokens.db") + if err == nil { + t.Fatal("expected error") + } +} + +func TestOpen_MigrateError(t *testing.T) { + prev := migrateExec + migrateExec = func(db *sql.DB, schema string) error { return fmt.Errorf("migration failed") } + defer func() { migrateExec = prev }() + _, err := Open(t.TempDir() + "/tokens.db") + if err == nil { + t.Fatal("expected error") + } +} + +func TestOpenWithPricing_Nil(t *testing.T) { + path := filepath.Join(t.TempDir(), "tokens.db") + s, err := OpenWithPricing(path, nil) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if _, ok := s.Pricing()["gpt-4o"]; !ok { + t.Error("OpenWithPricing(nil) should keep defaults") + } +} + +func TestClose_Nil(t *testing.T) { + var s *Store + if err := s.Close(); err != nil { + t.Errorf("nil Close should return nil, got %v", err) + } +} + +func TestClose_NilDB(t *testing.T) { + s := &Store{} + if err := s.Close(); err != nil { + t.Errorf("Close with nil db should return nil, got %v", err) + } +} + +func TestComputeCost_SubstringFallback(t *testing.T) { + // A model that does not have an exact match but contains a known + // substring ("llama-3.3-70b") should use the substring rate. + s, cleanup := tempStore(t) + defer cleanup() + e := Event{Model: "my-custom-llama-3.3-70b-thing", Source: SourceChat, TotalTokens: 1000} + if err := s.Record(context.Background(), e); err != nil { + t.Fatal(err) + } + tail, _ := s.Tail(context.Background(), Filter{}, 1) + want := 1000.0 * 0.0009 / 1000.0 + if len(tail) != 1 || absFloat(tail[0].CostUSD-want) > 1e-9 { + t.Errorf("substring cost: got %v, want %v", tail[0].CostUSD, want) + } +} + +func TestBuildWhere_SourceAndModel(t *testing.T) { + where, args := buildWhere(Filter{Source: SourceChat, Model: "gpt-4o"}) + if where == "" { + t.Fatal("expected WHERE clause") + } + if len(args) != 2 || args[0] != string(SourceChat) || args[1] != "gpt-4o" { + t.Errorf("unexpected args: %v", args) + } +} + +func TestAggregate_FilterBySourceAndModel(t *testing.T) { + s, cleanup := tempStore(t) + defer cleanup() + _ = s.Record(context.Background(), Event{Source: SourceChat, Model: "gpt-4o", TotalTokens: 100}) + _ = s.Record(context.Background(), Event{Source: SourceJudge, Model: "gpt-4o", TotalTokens: 50}) + _ = s.Record(context.Background(), Event{Source: SourceChat, Model: "claude-sonnet-4", TotalTokens: 200}) + top, _, err := s.Aggregate(context.Background(), Filter{Source: SourceChat, Model: "gpt-4o"}, "") + if err != nil { + t.Fatal(err) + } + if top.TotalTokens != 100 { + t.Errorf("expected 100 tokens, got %d", top.TotalTokens) + } + if top.EventCount != 1 { + t.Errorf("expected 1 event, got %d", top.EventCount) + } +} + +func TestAggregate_EmptyStore(t *testing.T) { + s, cleanup := tempStore(t) + defer cleanup() + top, subs, err := s.Aggregate(context.Background(), Filter{}, "") + if err != nil { + t.Fatal(err) + } + if top.EventCount != 0 || top.TotalTokens != 0 { + t.Errorf("expected empty aggregate: %+v", top) + } + if subs != nil { + t.Errorf("expected nil subs, got %d", len(subs)) + } +} + +func TestAggregate_BadDateParse(t *testing.T) { + s, cleanup := tempStore(t) + defer cleanup() + // Insert a raw row with an unparseable created_at to exercise the + // parse-fallback path. + _, err := s.db.Exec(`INSERT INTO usage_events (id, created_at) VALUES (?, ?)`, "bad-date", "not-a-date") + if err != nil { + t.Fatal(err) + } + top, _, err := s.Aggregate(context.Background(), Filter{}, "") + if err != nil { + t.Fatal(err) + } + if !top.FirstEvent.IsZero() || !top.LastEvent.IsZero() { + t.Errorf("bad dates should leave FirstEvent/LastEvent zero: %+v", top) + } +} + +func TestTail_ScanError(t *testing.T) { + s, cleanup := tempStore(t) + defer cleanup() + // Insert a raw row that violates the total_tokens type so Scan fails. + _, err := s.db.Exec(`INSERT INTO usage_events (id, session_id, model, source, input_tokens, output_tokens, total_tokens, cost_usd, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, "bad", "s", "m", "chat", 0, 0, "not-an-int", 0, "2026-06-16T12:00:00Z") + if err != nil { + t.Fatal(err) + } + _, err = s.Tail(context.Background(), Filter{}, 10) + if err == nil { + t.Fatal("expected scan error from corrupt total_tokens") + } +} + +func TestCount_WithFilter(t *testing.T) { + s, cleanup := tempStore(t) + defer cleanup() + _ = s.Record(context.Background(), Event{Source: SourceChat, Model: "gpt-4o", TotalTokens: 100}) + _ = s.Record(context.Background(), Event{Source: SourceJudge, Model: "gpt-4o", TotalTokens: 50}) + n, err := s.Count(context.Background(), Filter{Source: SourceChat}) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Errorf("expected 1 chat event, got %d", n) + } +} + +func TestDefaultPath_HomeFallback(t *testing.T) { + prev := userHomeDir + userHomeDir = func() (string, error) { return "/tmp/home", nil } + defer func() { userHomeDir = prev }() + t.Setenv("SIN_CODE_TOKENS_DB", "") + t.Setenv("XDG_DATA_HOME", "") + got := DefaultPath() + want := filepath.Join("/tmp/home", ".local", "share", "sin-code", "tokens.db") + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestOpen_EmptyPath(t *testing.T) { + prev := userHomeDir + userHomeDir = func() (string, error) { return t.TempDir(), nil } + defer func() { userHomeDir = prev }() + t.Setenv("SIN_CODE_TOKENS_DB", "") + t.Setenv("XDG_DATA_HOME", "") + s, err := Open("") + if err != nil { + t.Fatal(err) + } + defer s.Close() +} + +func TestAggregate_QueryError(t *testing.T) { + s, cleanup := tempStore(t) + cleanup() // close DB + _, _, err := s.Aggregate(context.Background(), Filter{}, "") + if err == nil { + t.Fatal("expected error on closed DB") + } +} + +func TestCount_Error(t *testing.T) { + s, cleanup := tempStore(t) + cleanup() // close DB + _, err := s.Count(context.Background(), Filter{}) + if err == nil { + t.Fatal("expected error on closed DB") + } +} + +func TestScanBreakdowns_QueryError(t *testing.T) { + s, cleanup := tempStore(t) + defer cleanup() + + prev := queryRows + defer func() { queryRows = prev }() + + queryRows = func(ctx context.Context, db *sql.DB, query string, args ...any) (*sql.Rows, error) { + return nil, fmt.Errorf("query failed") + } + err := scanBreakdowns(context.Background(), s.db, Filter{}, "", nil, &Aggregation{ByModel: map[string]int{}, BySource: map[string]int{}}) + if err == nil { + t.Fatal("expected query error") + } +} + +func TestScanBreakdowns_RowsCloseError(t *testing.T) { + s, cleanup := tempStore(t) + defer cleanup() + + prev := rowsClose + defer func() { rowsClose = prev }() + + rowsClose = func(r *sql.Rows) error { return fmt.Errorf("close failed") } + err := scanBreakdowns(context.Background(), s.db, Filter{}, "", nil, &Aggregation{ByModel: map[string]int{}, BySource: map[string]int{}}) + if err == nil { + t.Fatal("expected close error") + } +} + +func TestScanBreakdowns_ScanError(t *testing.T) { + s, cleanup := tempStore(t) + defer cleanup() + + prev := queryRows + defer func() { queryRows = prev }() + + calls := 0 + queryRows = func(ctx context.Context, db *sql.DB, query string, args ...any) (*sql.Rows, error) { + calls++ + if calls == 1 { + return db.QueryContext(ctx, "SELECT 'm', 0") + } + // Return rows whose second column is text, so Scan into int fails. + return db.QueryContext(ctx, "SELECT 's', 'bad'") + } + err := scanBreakdowns(context.Background(), s.db, Filter{}, "", nil, &Aggregation{ByModel: map[string]int{}, BySource: map[string]int{}}) + if err == nil { + t.Fatal("expected scan error") + } +} + +func TestScanBreakdowns_FirstScanError(t *testing.T) { + s, cleanup := tempStore(t) + defer cleanup() + + prev := queryRows + defer func() { queryRows = prev }() + + queryRows = func(ctx context.Context, db *sql.DB, query string, args ...any) (*sql.Rows, error) { + return db.QueryContext(ctx, "SELECT 'm', 'bad'") + } + err := scanBreakdowns(context.Background(), s.db, Filter{}, "", nil, &Aggregation{ByModel: map[string]int{}, BySource: map[string]int{}}) + if err == nil { + t.Fatal("expected scan error") + } +} + +func TestScanBreakdowns_SecondQueryError(t *testing.T) { + s, cleanup := tempStore(t) + defer cleanup() + + prev := queryRows + defer func() { queryRows = prev }() + + calls := 0 + queryRows = func(ctx context.Context, db *sql.DB, query string, args ...any) (*sql.Rows, error) { + calls++ + if calls == 1 { + return db.QueryContext(ctx, "SELECT 'm', 0") + } + return nil, fmt.Errorf("second query failed") + } + err := scanBreakdowns(context.Background(), s.db, Filter{}, "", nil, &Aggregation{ByModel: map[string]int{}, BySource: map[string]int{}}) + if err == nil { + t.Fatal("expected second query error") + } +} + +func TestAggregate_SubQueryError(t *testing.T) { + s, cleanup := tempStore(t) + defer cleanup() + _ = s.Record(context.Background(), Event{Source: SourceChat, TotalTokens: 100}) + + prev := aggregateQuery + defer func() { aggregateQuery = prev }() + + aggregateQuery = func(ctx context.Context, db *sql.DB, query string, args ...any) (*sql.Rows, error) { + return nil, fmt.Errorf("sub-query failed") + } + _, _, err := s.Aggregate(context.Background(), Filter{}, "day") + if err == nil { + t.Fatal("expected sub-query error") + } +} + +func TestAggregate_SubScanError(t *testing.T) { + s, cleanup := tempStore(t) + defer cleanup() + _ = s.Record(context.Background(), Event{Source: SourceChat, TotalTokens: 100}) + + prev := aggregateQuery + defer func() { aggregateQuery = prev }() + + aggregateQuery = func(ctx context.Context, db *sql.DB, query string, args ...any) (*sql.Rows, error) { + return db.QueryContext(ctx, "SELECT 'g', 'bad', 0, 0, 0, 0, '', ''") + } + _, _, err := s.Aggregate(context.Background(), Filter{}, "day") + if err == nil { + t.Fatal("expected sub-scan error") + } +} + +func TestTail_QueryError(t *testing.T) { + s, cleanup := tempStore(t) + cleanup() // close DB + _, err := s.Tail(context.Background(), Filter{}, 10) + if err == nil { + t.Fatal("expected query error") + } +} + +func TestOpenWithPricing_Error(t *testing.T) { + prev := mkdirAll + mkdirAll = func(path string, perm os.FileMode) error { return fmt.Errorf("mkdir denied") } + defer func() { mkdirAll = prev }() + _, err := OpenWithPricing("/some/path/tokens.db", nil) + if err == nil { + t.Fatal("expected error") + } +}