diff --git a/cmd/sin-code/internal/apiweb/api.go b/cmd/sin-code/internal/apiweb/api.go
index e067ee2d..94f71d75 100644
--- a/cmd/sin-code/internal/apiweb/api.go
+++ b/cmd/sin-code/internal/apiweb/api.go
@@ -21,6 +21,22 @@ import (
"github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/session"
)
+// package-level hooks to make error branches testable without relying on
+// real SQLite failures or full stack wiring.
+var (
+ sessionOpenHook = session.Open
+ lessonsOpenHook = lessons.Open
+
+ sessionListHook = func(s *session.Store) ([]session.Info, error) { return s.List() }
+ sessionStartOrResumeHook = func(s *session.Store, id string) (*session.Session, error) { return s.StartOrResume(id) }
+ sessionDeleteHook = func(s *session.Store, id string) error { return s.Delete(id) }
+ sessionForkHook = func(s *session.Store, src string, turn int) (*session.Session, error) { return s.Fork(src, turn) }
+
+ lessonsQueryHook = func(s *lessons.Store, ctx context.Context, workspace string, limit int) ([]lessons.Entry, error) {
+ return s.Query(ctx, workspace, limit)
+ }
+)
+
// chatRequest is the body for POST /api/v1/chat.
type chatRequest struct {
Prompt string `json:"prompt"`
@@ -135,7 +151,7 @@ func (a *APIServer) stores() (*session.Store, *lessons.Store, error) {
if sessPath == "" {
sessPath = session.DefaultPath()
}
- sstore, err := session.Open(sessPath)
+ sstore, err := sessionOpenHook(sessPath)
if err != nil {
return nil, nil, fmt.Errorf("open sessions: %w", err)
}
@@ -143,7 +159,7 @@ func (a *APIServer) stores() (*session.Store, *lessons.Store, error) {
if lessPath == "" {
lessPath = lessons.DefaultPath()
}
- lstore, err := lessons.Open(lessPath)
+ lstore, err := lessonsOpenHook(lessPath)
if err != nil {
_ = sstore.Close()
return nil, nil, fmt.Errorf("open lessons: %w", err)
@@ -162,7 +178,7 @@ func (a *APIServer) handleListSessions(w http.ResponseWriter, r *http.Request) {
}
defer sstore.Close()
defer lstore.Close()
- infos, err := sstore.List()
+ infos, err := sessionListHook(sstore)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -188,7 +204,7 @@ func (a *APIServer) handleShowSession(w http.ResponseWriter, r *http.Request) {
return
}
defer sstore.Close()
- sess, err := sstore.StartOrResume(id)
+ sess, err := sessionStartOrResumeHook(sstore, id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
@@ -214,7 +230,7 @@ func (a *APIServer) handleDeleteSession(w http.ResponseWriter, r *http.Request)
return
}
defer sstore.Close()
- if err := sstore.Delete(id); err != nil {
+ if err := sessionDeleteHook(sstore, id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -245,7 +261,7 @@ func (a *APIServer) handleForkSession(w http.ResponseWriter, r *http.Request) {
return
}
defer sstore.Close()
- forked, err := sstore.Fork(id, body.Turn)
+ forked, err := sessionForkHook(sstore, id, body.Turn)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
@@ -274,7 +290,7 @@ func (a *APIServer) handleKnowledge(w http.ResponseWriter, r *http.Request) {
limit = n
}
}
- entries, err := lstore.Query(r.Context(), a.Workspace, limit)
+ entries, err := lessonsQueryHook(lstore, r.Context(), a.Workspace, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -340,7 +356,7 @@ func (a *APIServer) handleChat(w http.ResponseWriter, r *http.Request) {
return
}
defer sstore.Close()
- sess, err := sstore.StartOrResume(body.SessionID)
+ sess, err := sessionStartOrResumeHook(sstore, body.SessionID)
if err != nil {
writeSSE(w, flusher, sseEvent{Event: "error", Data: map[string]string{"error": err.Error()}})
return
diff --git a/cmd/sin-code/internal/apiweb/coverage_test.go b/cmd/sin-code/internal/apiweb/coverage_test.go
new file mode 100644
index 00000000..faaaa9de
--- /dev/null
+++ b/cmd/sin-code/internal/apiweb/coverage_test.go
@@ -0,0 +1,373 @@
+// SPDX-License-Identifier: MIT
+// Purpose: additional coverage tests to reach 100% statement coverage.
+package apiweb
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/agentloop"
+ "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/lessons"
+ "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/session"
+)
+
+// nonFlusherRecorder is an http.ResponseWriter that does NOT implement
+// http.Flusher, exercising the chat handler's streaming-unsupported path.
+type nonFlusherRecorder struct {
+ rec *httptest.ResponseRecorder
+}
+
+func (n nonFlusherRecorder) Header() http.Header { return n.rec.Header() }
+func (n nonFlusherRecorder) Write(b []byte) (int, error) { return n.rec.Write(b) }
+func (n nonFlusherRecorder) WriteHeader(code int) { n.rec.WriteHeader(code) }
+
+// nonFlusherHandler wraps a handler with a non-flushing ResponseWriter.
+type nonFlusherHandler struct{ h http.Handler }
+
+func (n nonFlusherHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ rec := httptest.NewRecorder()
+ n.h.ServeHTTP(nonFlusherRecorder{rec}, r)
+ // Copy recorded state back to the real writer.
+ for k, v := range rec.Header() {
+ w.Header()[k] = v
+ }
+ w.WriteHeader(rec.Code)
+ _, _ = w.Write(rec.Body.Bytes())
+}
+
+// newTestAPIServerWithAPI is like newTestAPIServer but leaves DB paths empty
+// so the default-path branches can be exercised.
+func newTestAPIServerWithAPI(t *testing.T, token string) (*APIServer, *httptest.Server) {
+ t.Helper()
+ dir := t.TempDir()
+ api := NewAPIServer(dir)
+ api.Token = token
+ api.SessionDB = ""
+ api.LessonsDB = ""
+ mux := http.NewServeMux()
+ api.Routes(mux)
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ return api, srv
+}
+
+// ─── auth edge cases ───────────────────────────────────────────────────
+
+func TestAPIAuth_MalformedRemoteAddr(t *testing.T) {
+ api := NewAPIServer(t.TempDir())
+ api.Token = "" // loopback-only
+ mux := http.NewServeMux()
+ api.Routes(mux)
+ wrapped := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ r.RemoteAddr = "not-a-valid-addr"
+ mux.ServeHTTP(w, r)
+ })
+ srv := httptest.NewServer(wrapped)
+ defer srv.Close()
+ resp, _ := doJSON(t, "GET", srv.URL+"/api/v1/sessions", "", nil)
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("want 401, got %d", resp.StatusCode)
+ }
+}
+
+// ─── default store open error paths ────────────────────────────────────
+
+func TestAPI_DefaultSessionPath_OpenError(t *testing.T) {
+ _, srv := newTestAPIServerWithAPI(t, "tok")
+ old := sessionOpenHook
+ sessionOpenHook = func(path string) (*session.Store, error) {
+ return nil, fmt.Errorf("session open failed")
+ }
+ defer func() { sessionOpenHook = old }()
+
+ resp, _ := doJSON(t, "GET", srv.URL+"/api/v1/sessions", "tok", nil)
+ if resp.StatusCode != http.StatusInternalServerError {
+ t.Fatalf("want 500, got %d", resp.StatusCode)
+ }
+}
+
+func TestAPI_DefaultLessonsPath_OpenError(t *testing.T) {
+ _, srv := newTestAPIServerWithAPI(t, "tok")
+ old := lessonsOpenHook
+ lessonsOpenHook = func(path string) (*lessons.Store, error) {
+ return nil, fmt.Errorf("lessons open failed")
+ }
+ defer func() { lessonsOpenHook = old }()
+
+ resp, _ := doJSON(t, "GET", srv.URL+"/api/v1/knowledge", "tok", nil)
+ if resp.StatusCode != http.StatusInternalServerError {
+ t.Fatalf("want 500, got %d", resp.StatusCode)
+ }
+}
+
+// ─── handler-specific auth failures ────────────────────────────────────
+
+func TestAPI_ShowSession_AuthRejects(t *testing.T) {
+ _, srv := newTestAPIServerWithAPI(t, "tok")
+ resp, _ := doJSON(t, "GET", srv.URL+"/api/v1/sessions/abc", "wrong", nil)
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("want 401, got %d", resp.StatusCode)
+ }
+}
+
+func TestAPI_ShowSession_MissingID(t *testing.T) {
+ api, _ := newTestAPIServerWithAPI(t, "tok")
+ req := httptest.NewRequest("GET", "/api/v1/sessions/", nil)
+ req.Header.Set("Authorization", "Bearer tok")
+ w := httptest.NewRecorder()
+ api.handleShowSession(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("want 400, got %d", w.Code)
+ }
+}
+
+func TestAPI_DeleteSession_AuthRejects(t *testing.T) {
+ _, srv := newTestAPIServerWithAPI(t, "tok")
+ resp, _ := doJSON(t, "DELETE", srv.URL+"/api/v1/sessions/abc", "wrong", nil)
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("want 401, got %d", resp.StatusCode)
+ }
+}
+
+func TestAPI_DeleteSession_MissingID(t *testing.T) {
+ api, _ := newTestAPIServerWithAPI(t, "tok")
+ req := httptest.NewRequest("DELETE", "/api/v1/sessions/", nil)
+ req.Header.Set("Authorization", "Bearer tok")
+ w := httptest.NewRecorder()
+ api.handleDeleteSession(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("want 400, got %d", w.Code)
+ }
+}
+
+func TestAPI_ForkSession_AuthRejects(t *testing.T) {
+ _, srv := newTestAPIServerWithAPI(t, "tok")
+ resp, _ := doJSON(t, "POST", srv.URL+"/api/v1/sessions/abc/fork", "wrong", map[string]int{"turn": 1})
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("want 401, got %d", resp.StatusCode)
+ }
+}
+
+func TestAPI_ForkSession_MissingID(t *testing.T) {
+ api, _ := newTestAPIServerWithAPI(t, "tok")
+ req := httptest.NewRequest("POST", "/api/v1/sessions/", nil)
+ req.Header.Set("Authorization", "Bearer tok")
+ w := httptest.NewRecorder()
+ api.handleForkSession(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("want 400, got %d", w.Code)
+ }
+}
+
+func TestAPI_Knowledge_AuthRejects(t *testing.T) {
+ _, srv := newTestAPIServerWithAPI(t, "tok")
+ resp, _ := doJSON(t, "GET", srv.URL+"/api/v1/knowledge", "wrong", nil)
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("want 401, got %d", resp.StatusCode)
+ }
+}
+
+func TestAPI_Chat_AuthRejects(t *testing.T) {
+ _, srv := newTestAPIServerWithAPI(t, "tok")
+ resp, _ := doJSON(t, "POST", srv.URL+"/api/v1/chat", "wrong", map[string]string{"prompt": "hi"})
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("want 401, got %d", resp.StatusCode)
+ }
+}
+
+// ─── store method error injection ──────────────────────────────────────
+
+func TestAPI_ListSessions_StoreError(t *testing.T) {
+ api, srv := newTestAPIServerWithAPI(t, "tok")
+ api.OpenStores = func() (*session.Store, *lessons.Store, error) {
+ s, _ := session.Open(filepath.Join(t.TempDir(), "s.db"))
+ l, _ := lessons.Open(filepath.Join(t.TempDir(), "l.db"))
+ return s, l, nil
+ }
+ old := sessionListHook
+ sessionListHook = func(s *session.Store) ([]session.Info, error) {
+ return nil, fmt.Errorf("list failed")
+ }
+ defer func() { sessionListHook = old }()
+
+ resp, _ := doJSON(t, "GET", srv.URL+"/api/v1/sessions", "tok", nil)
+ if resp.StatusCode != http.StatusInternalServerError {
+ t.Fatalf("want 500, got %d", resp.StatusCode)
+ }
+}
+
+func TestAPI_DeleteSession_StoreError(t *testing.T) {
+ api, srv := newTestAPIServerWithAPI(t, "tok")
+ api.OpenStores = func() (*session.Store, *lessons.Store, error) {
+ s, _ := session.Open(filepath.Join(t.TempDir(), "s.db"))
+ l, _ := lessons.Open(filepath.Join(t.TempDir(), "l.db"))
+ return s, l, nil
+ }
+ old := sessionDeleteHook
+ sessionDeleteHook = func(s *session.Store, id string) error {
+ return fmt.Errorf("delete failed")
+ }
+ defer func() { sessionDeleteHook = old }()
+
+ resp, _ := doJSON(t, "DELETE", srv.URL+"/api/v1/sessions/abc", "tok", nil)
+ if resp.StatusCode != http.StatusInternalServerError {
+ t.Fatalf("want 500, got %d", resp.StatusCode)
+ }
+}
+
+func TestAPI_ForkSession_StoreError(t *testing.T) {
+ api, srv := newTestAPIServerWithAPI(t, "tok")
+ api.OpenStores = func() (*session.Store, *lessons.Store, error) {
+ s, _ := session.Open(filepath.Join(t.TempDir(), "s.db"))
+ l, _ := lessons.Open(filepath.Join(t.TempDir(), "l.db"))
+ return s, l, nil
+ }
+ old := sessionForkHook
+ sessionForkHook = func(s *session.Store, src string, turn int) (*session.Session, error) {
+ return nil, fmt.Errorf("fork failed")
+ }
+ defer func() { sessionForkHook = old }()
+
+ resp, _ := doJSON(t, "POST", srv.URL+"/api/v1/sessions/abc/fork", "tok", map[string]int{"turn": 1})
+ if resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("want 404, got %d", resp.StatusCode)
+ }
+}
+
+func TestAPI_Knowledge_Limit(t *testing.T) {
+ api, srv := newTestAPIServerWithAPI(t, "tok")
+ api.OpenStores = func() (*session.Store, *lessons.Store, error) {
+ s, _ := session.Open(filepath.Join(t.TempDir(), "s.db"))
+ l, _ := lessons.Open(filepath.Join(t.TempDir(), "l.db"))
+ return s, l, nil
+ }
+ old := lessonsQueryHook
+ called := false
+ lessonsQueryHook = func(s *lessons.Store, ctx context.Context, workspace string, limit int) ([]lessons.Entry, error) {
+ called = true
+ if limit != 7 {
+ t.Fatalf("want limit 7, got %d", limit)
+ }
+ return []lessons.Entry{}, nil
+ }
+ defer func() { lessonsQueryHook = old }()
+
+ resp, _ := doJSON(t, "GET", srv.URL+"/api/v1/knowledge?limit=7", "tok", nil)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("want 200, got %d", resp.StatusCode)
+ }
+ if !called {
+ t.Fatal("lessonsQueryHook was not called")
+ }
+}
+
+func TestAPI_Knowledge_QueryError(t *testing.T) {
+ api, srv := newTestAPIServerWithAPI(t, "tok")
+ api.OpenStores = func() (*session.Store, *lessons.Store, error) {
+ s, _ := session.Open(filepath.Join(t.TempDir(), "s.db"))
+ l, _ := lessons.Open(filepath.Join(t.TempDir(), "l.db"))
+ return s, l, nil
+ }
+ old := lessonsQueryHook
+ lessonsQueryHook = func(s *lessons.Store, ctx context.Context, workspace string, limit int) ([]lessons.Entry, error) {
+ return nil, fmt.Errorf("query failed")
+ }
+ defer func() { lessonsQueryHook = old }()
+
+ resp, _ := doJSON(t, "GET", srv.URL+"/api/v1/knowledge", "tok", nil)
+ if resp.StatusCode != http.StatusInternalServerError {
+ t.Fatalf("want 500, got %d", resp.StatusCode)
+ }
+}
+
+// ─── chat error paths ──────────────────────────────────────────────────
+
+func TestAPI_Chat_FlusherNotOK(t *testing.T) {
+ api, _ := newTestAPIServerWithAPI(t, "tok")
+ api.OpenStores = func() (*session.Store, *lessons.Store, error) {
+ s, _ := session.Open(filepath.Join(t.TempDir(), "s.db"))
+ l, _ := lessons.Open(filepath.Join(t.TempDir(), "l.db"))
+ return s, l, nil
+ }
+
+ wrapped := httptest.NewServer(nonFlusherHandler{api.Routes(nil)})
+ defer wrapped.Close()
+
+ req, _ := http.NewRequest("POST", wrapped.URL+"/api/v1/chat", strings.NewReader(`{"prompt":"hi"}`))
+ req.Header.Set("Authorization", "Bearer tok")
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusInternalServerError {
+ t.Fatalf("want 500, got %d", resp.StatusCode)
+ }
+}
+
+func TestAPI_Chat_StartOrResumeError(t *testing.T) {
+ api, srv := newTestAPIServerWithAPI(t, "tok")
+ api.OpenStores = func() (*session.Store, *lessons.Store, error) {
+ s, _ := session.Open(filepath.Join(t.TempDir(), "s.db"))
+ l, _ := lessons.Open(filepath.Join(t.TempDir(), "l.db"))
+ return s, l, nil
+ }
+ api.NewLoop = happyChatLoopFactory(&agentloop.Result{}, nil)
+
+ old := sessionStartOrResumeHook
+ sessionStartOrResumeHook = func(s *session.Store, id string) (*session.Session, error) {
+ return nil, fmt.Errorf("resume failed")
+ }
+ defer func() { sessionStartOrResumeHook = old }()
+
+ req, _ := http.NewRequest("POST", srv.URL+"/api/v1/chat", strings.NewReader(`{"prompt":"hi"}`))
+ req.Header.Set("Authorization", "Bearer tok")
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("want 200, got %d", resp.StatusCode)
+ }
+ events := parseSSE(t, resp.Body)
+ last := events[len(events)-1]
+ if last.Event != "error" {
+ t.Fatalf("want error event, got %q", last.Event)
+ }
+}
+
+func TestAPI_Chat_NoNewLoop(t *testing.T) {
+ api, srv := newTestAPIServerWithAPI(t, "tok")
+ api.OpenStores = func() (*session.Store, *lessons.Store, error) {
+ s, _ := session.Open(filepath.Join(t.TempDir(), "s.db"))
+ l, _ := lessons.Open(filepath.Join(t.TempDir(), "l.db"))
+ return s, l, nil
+ }
+ api.NewLoop = nil
+
+ req, _ := http.NewRequest("POST", srv.URL+"/api/v1/chat", strings.NewReader(`{"prompt":"hi"}`))
+ req.Header.Set("Authorization", "Bearer tok")
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("want 200, got %d", resp.StatusCode)
+ }
+ body, _ := io.ReadAll(resp.Body)
+ if !bytes.Contains(body, []byte("no NewLoop factory wired")) {
+ t.Fatalf("expected no NewLoop error, got %s", body)
+ }
+}
diff --git a/cmd/sin-code/internal/assets/asset.go b/cmd/sin-code/internal/assets/asset.go
index eec8a36d..8c42936b 100644
--- a/cmd/sin-code/internal/assets/asset.go
+++ b/cmd/sin-code/internal/assets/asset.go
@@ -12,6 +12,9 @@ import (
"gopkg.in/yaml.v3"
)
+// yamlMarshalHook is swapable for testing the Render error branch.
+var yamlMarshalHook = yaml.Marshal
+
// Kind distinguishes the asset families harvested from ECC.
type Kind string
@@ -63,7 +66,7 @@ func ParseAsset(kind Kind, path string, data []byte) (*Asset, error) {
// Render reassembles the asset back into canonical Markdown (for re-export).
func (a *Asset) Render() ([]byte, error) {
- fm, err := yaml.Marshal(a)
+ fm, err := yamlMarshalHook(a)
if err != nil {
return nil, err
}
diff --git a/cmd/sin-code/internal/assets/coverage_test.go b/cmd/sin-code/internal/assets/coverage_test.go
new file mode 100644
index 00000000..f9faead6
--- /dev/null
+++ b/cmd/sin-code/internal/assets/coverage_test.go
@@ -0,0 +1,756 @@
+// SPDX-License-Identifier: MIT
+// Purpose: additional coverage tests to reach 100% statement coverage.
+package assets
+
+import (
+ "bytes"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "gopkg.in/yaml.v3"
+)
+
+// captureStdout runs f and returns everything written to os.Stdout.
+func captureStdout(t *testing.T, f func()) string {
+ t.Helper()
+ old := os.Stdout
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ os.Stdout = w
+ outCh := make(chan string)
+ go func() {
+ var buf bytes.Buffer
+ _, _ = buf.ReadFrom(r)
+ outCh <- buf.String()
+ }()
+ f()
+ _ = w.Close()
+ os.Stdout = old
+ return <-outCh
+}
+
+// writeFile writes a file with 0o644 permissions.
+func writeFile(t *testing.T, path, content string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// ─── ParseAsset / Render ─────────────────────────────────────────────────
+
+func TestParseAsset_MissingFrontmatter(t *testing.T) {
+ _, err := ParseAsset(KindSkill, "x.md", []byte("no frontmatter"))
+ if err == nil || !strings.Contains(err.Error(), "missing frontmatter") {
+ t.Fatalf("expected missing frontmatter error, got %v", err)
+ }
+}
+
+func TestParseAsset_UnterminatedFrontmatter(t *testing.T) {
+ _, err := ParseAsset(KindSkill, "x.md", []byte("---\nname: x\n"))
+ if err == nil || !strings.Contains(err.Error(), "unterminated frontmatter") {
+ t.Fatalf("expected unterminated frontmatter error, got %v", err)
+ }
+}
+
+func TestParseAsset_BadYAML(t *testing.T) {
+ _, err := ParseAsset(KindSkill, "x.md", []byte("---\nname: [bad\n---\nbody"))
+ if err == nil || !strings.Contains(err.Error(), "parse frontmatter") {
+ t.Fatalf("expected parse frontmatter error, got %v", err)
+ }
+}
+
+func TestRender_YAMLError(t *testing.T) {
+ old := yamlMarshalHook
+ yamlMarshalHook = func(v any) ([]byte, error) {
+ return nil, fmt.Errorf("marshal failed")
+ }
+ defer func() { yamlMarshalHook = old }()
+
+ a := &Asset{Kind: KindSkill, Name: "x", Body: "body"}
+ _, err := a.Render()
+ if err == nil || !strings.Contains(err.Error(), "marshal failed") {
+ t.Fatalf("expected marshal error, got %v", err)
+ }
+}
+
+// ─── Registry / Selector ─────────────────────────────────────────────────
+
+func TestRegistry_GetAndForDomain(t *testing.T) {
+ r := NewRegistry()
+ r.Add(&Asset{Kind: KindAgent, Name: "go", Domain: "go"})
+ r.Add(&Asset{Kind: KindSkill, Name: "rust", Domain: "rust"})
+
+ if _, ok := r.Get(KindAgent, "go"); !ok {
+ t.Fatal("Get should find go agent")
+ }
+ if _, ok := r.Get(KindAgent, "missing"); ok {
+ t.Fatal("Get should not find missing")
+ }
+ if got := r.ForDomain("rust"); len(got) != 1 || got[0].Name != "rust" {
+ t.Fatalf("ForDomain: %v", got)
+ }
+}
+
+func TestSelector_SelectCommandsAndTieBreak(t *testing.T) {
+ r := NewRegistry()
+ r.AddAll([]*Asset{
+ {Kind: KindCommand, Name: "z-cmd", Description: "z"},
+ {Kind: KindCommand, Name: "a-cmd", Description: "a"},
+ })
+ sel := NewSelector(r)
+ matches := sel.SelectCommands(Context{Keywords: []string{"cmd"}}, 1)
+ if len(matches) != 1 || matches[0].Asset.Name != "a-cmd" {
+ t.Fatalf("expected a-cmd first on tie-break, got %+v", matches)
+ }
+}
+
+func TestSelector_NameKeywordAndEmpty(t *testing.T) {
+ r := NewRegistry()
+ r.Add(&Asset{Kind: KindAgent, Name: "go-reviewer", Description: "reviews"})
+ sel := NewSelector(r)
+ if got := sel.SelectAgents(Context{Keywords: []string{"go", ""}}, 3); len(got) != 1 || got[0].Score != 4 {
+ t.Fatalf("expected name keyword match, got %+v", got)
+ }
+}
+
+func TestSelector_SortByScore(t *testing.T) {
+ r := NewRegistry()
+ r.AddAll([]*Asset{
+ {Kind: KindAgent, Name: "keyword-only", Domain: "", Description: "has reviews"},
+ {Kind: KindAgent, Name: "domain-match", Domain: "go", Description: "go agent"},
+ })
+ sel := NewSelector(r)
+ got := sel.SelectAgents(Context{Domain: "go", Keywords: []string{"reviews"}}, 3)
+ if len(got) != 2 {
+ t.Fatalf("expected 2 matches, got %+v", got)
+ }
+ if got[0].Asset.Name != "domain-match" {
+ t.Fatalf("expected domain-match first (higher score), got %+v", got)
+ }
+}
+
+// ─── LoadDir / LoadStandardLayout ───────────────────────────────────────
+
+func TestLoadDir_SkipsNonMD(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "skip.txt"), "---\n---\n")
+ writeFile(t, filepath.Join(dir, "ok.md"), "---\nname: ok\n---\n")
+ loaded, err := LoadDir(dir, KindAgent)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(loaded) != 1 || loaded[0].Name != "ok" {
+ t.Fatalf("expected one agent, got %+v", loaded)
+ }
+}
+
+func TestLoadDir_SkipsNonSkillMD(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "x", "not-skill.md"), "---\nname: x\n---\n")
+ loaded, err := LoadDir(dir, KindSkill)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(loaded) != 0 {
+ t.Fatalf("expected 0 skills, got %d", len(loaded))
+ }
+}
+
+func TestLoadDir_DefaultNames(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "agents", "no-name.md"), "---\n---\nbody here is longer than twenty")
+ writeFile(t, filepath.Join(dir, "skills", "skill-name", "SKILL.md"), "---\n---\nbody here is longer than twenty")
+
+ agents, err := LoadDir(filepath.Join(dir, "agents"), KindAgent)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(agents) != 1 || agents[0].Name != "no-name" {
+ t.Fatalf("expected agent name 'no-name', got %+v", agents)
+ }
+
+ skills, err := LoadDir(filepath.Join(dir, "skills"), KindSkill)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(skills) != 1 || skills[0].Name != "skill-name" {
+ t.Fatalf("expected skill name 'skill-name', got %+v", skills)
+ }
+}
+
+func TestLoadDir_ParseError(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "bad.md"), "not frontmatter")
+ _, err := LoadDir(dir, KindAgent)
+ if err == nil {
+ t.Fatal("expected parse error")
+ }
+}
+
+func TestLoadDir_ReadError(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "bad.md"), "---\n---\n")
+
+ old := osReadFileHook
+ osReadFileHook = func(path string) ([]byte, error) {
+ return nil, fmt.Errorf("read failed")
+ }
+ defer func() { osReadFileHook = old }()
+
+ _, err := LoadDir(dir, KindAgent)
+ if err == nil || !strings.Contains(err.Error(), "read failed") {
+ t.Fatalf("expected read error, got %v", err)
+ }
+}
+
+func TestLoadDir_WalkError(t *testing.T) {
+ dir := t.TempDir()
+ old := walkDirHook
+ walkDirHook = func(root string, fn fs.WalkDirFunc) error {
+ return fmt.Errorf("walk failed")
+ }
+ defer func() { walkDirHook = old }()
+
+ _, err := LoadDir(dir, KindAgent)
+ if err == nil || !strings.Contains(err.Error(), "walk failed") {
+ t.Fatalf("expected walk error, got %v", err)
+ }
+}
+
+func TestLoadDir_WalkCallbackError(t *testing.T) {
+ dir := t.TempDir()
+ // Create an unreadable subdirectory so the WalkDir callback receives an error.
+ badDir := filepath.Join(dir, "bad")
+ if err := os.MkdirAll(badDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(badDir, 0o000); err != nil {
+ t.Fatal(err)
+ }
+ defer os.Chmod(badDir, 0o755)
+
+ _, err := LoadDir(dir, KindAgent)
+ if err == nil {
+ t.Fatal("expected walk callback error")
+ }
+}
+
+func TestLoadStandardLayout(t *testing.T) {
+ base := t.TempDir()
+ writeFile(t, filepath.Join(base, "agents", "a.md"), "---\nname: a\n---\n")
+ writeFile(t, filepath.Join(base, "commands", "c.md"), "---\nname: c\n---\n")
+ writeFile(t, filepath.Join(base, "skills", "s", "SKILL.md"), "---\nname: s\n---\n")
+
+ loaded, err := LoadStandardLayout(base)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(loaded) != 3 {
+ t.Fatalf("expected 3 assets, got %d", len(loaded))
+ }
+}
+
+// ─── Validate / ValidateAll ─────────────────────────────────────────────
+
+func TestValidate_AllIssueBranches(t *testing.T) {
+ cases := []struct {
+ name string
+ asset *Asset
+ want int // number of issues
+ }{
+ {
+ name: "missing name",
+ asset: &Asset{Kind: KindSkill, Name: "", Description: "d", Path: "x.md", Body: "## Section\n" +
+ strings.Repeat("x", 20)},
+ want: 1,
+ },
+ {
+ name: "missing description",
+ asset: &Asset{Kind: KindSkill, Name: "n", Description: "", Path: "x.md", Body: "## Section\n" +
+ strings.Repeat("x", 20)},
+ want: 1,
+ },
+ {
+ name: "short body",
+ asset: &Asset{Kind: KindSkill, Name: "n", Description: "d", Path: "x.md", Body: "short"},
+ want: 2,
+ },
+ {
+ name: "agent no model",
+ asset: &Asset{Kind: KindAgent, Name: "n", Description: "d", Path: "x.md", Body: strings.Repeat("x", 20)},
+ want: 2,
+ },
+ {
+ name: "command no dollar",
+ asset: &Asset{Kind: KindCommand, Name: "n", Description: "d", Path: "x.md", Argument: "arg", Body: "no placeholder"},
+ want: 2,
+ },
+ {
+ name: "skill no sections",
+ asset: &Asset{Kind: KindSkill, Name: "n", Description: "d", Path: "x.md", Body: strings.Repeat("x", 20)},
+ want: 1,
+ },
+ {
+ name: "unsafe unicode",
+ asset: &Asset{Kind: KindSkill, Name: "n", Description: "d", Path: "x.md", Body: "## S\n" + "\u200B"},
+ want: 2,
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ issues := Validate(tc.asset)
+ if len(issues) != tc.want {
+ t.Fatalf("want %d issues, got %d: %v", tc.want, len(issues), issues)
+ }
+ })
+ }
+}
+
+func TestValidateAll_Duplicate(t *testing.T) {
+ a1 := &Asset{Kind: KindSkill, Name: "x", Description: "d", Path: "a.md", Body: "## S\n"}
+ a2 := &Asset{Kind: KindSkill, Name: "x", Description: "d", Path: "b.md", Body: "## S\n"}
+ issues := ValidateAll([]*Asset{a1, a2})
+ found := false
+ for _, is := range issues {
+ if strings.Contains(is.Message, "duplicate") {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("expected duplicate issue, got %v", issues)
+ }
+}
+
+func TestContains_NoMatch(t *testing.T) {
+ if contains("hello", "xyz") {
+ t.Fatal("expected false")
+ }
+}
+
+// ─── ImportSkills ────────────────────────────────────────────────────────
+
+func TestImportSkills_LoadSourceError(t *testing.T) {
+ old := loadSourceSkillsHook
+ loadSourceSkillsHook = func(base string) ([]*Asset, error) {
+ return nil, fmt.Errorf("load failed")
+ }
+ defer func() { loadSourceSkillsHook = old }()
+
+ _, err := ImportSkills(ImportOptions{SourceBase: "src", DestDir: "dst"})
+ if err == nil || !strings.Contains(err.Error(), "load failed") {
+ t.Fatalf("expected load error, got %v", err)
+ }
+}
+
+func TestImportSkills_MkdirAllError(t *testing.T) {
+ old := osMkdirAllHook
+ osMkdirAllHook = func(path string, perm fs.FileMode) error {
+ return fmt.Errorf("mkdir failed")
+ }
+ defer func() { osMkdirAllHook = old }()
+
+ src := t.TempDir()
+ writeFile(t, filepath.Join(src, ".agents", "skills", "go-patterns", "SKILL.md"),
+ "---\nname: go-patterns\ndescription: d\n---\n\n## S\n"+strings.Repeat("x", 20))
+
+ _, err := ImportSkills(ImportOptions{SourceBase: src, DestDir: "dst", IncludeDomains: []string{"go"}})
+ if err == nil || !strings.Contains(err.Error(), "mkdir failed") {
+ t.Fatalf("expected mkdir error, got %v", err)
+ }
+}
+
+func TestImportSkills_DomainFilter(t *testing.T) {
+ src := t.TempDir()
+ dst := t.TempDir()
+ writeFile(t, filepath.Join(src, ".agents", "skills", "go-patterns", "SKILL.md"),
+ "---\nname: go-patterns\ndescription: d\n---\n\n## S\n"+strings.Repeat("x", 20))
+ writeFile(t, filepath.Join(src, ".agents", "skills", "rust-patterns", "SKILL.md"),
+ "---\nname: rust-patterns\ndescription: d\n---\n\n## S\n"+strings.Repeat("x", 20))
+
+ rep, err := ImportSkills(ImportOptions{SourceBase: src, DestDir: dst, IncludeDomains: []string{"go"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rep.Imported != 1 || rep.Skipped != 1 {
+ t.Fatalf("expected 1 imported, 1 skipped, got %+v", rep)
+ }
+}
+
+func TestImportSkills_InvalidAsset(t *testing.T) {
+ src := t.TempDir()
+ dst := t.TempDir()
+ writeFile(t, filepath.Join(src, ".agents", "skills", "bad", "SKILL.md"),
+ "---\nname: bad\ndescription: \n---\n\n## S\n"+strings.Repeat("x", 20))
+
+ rep, err := ImportSkills(ImportOptions{SourceBase: src, DestDir: dst, IncludeDomains: []string{"bad"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rep.Invalid != 1 || rep.Imported != 0 {
+ t.Fatalf("expected 1 invalid, 0 imported, got %+v", rep)
+ }
+ if len(rep.Issues) != 1 {
+ t.Fatalf("expected 1 issue, got %+v", rep.Issues)
+ }
+}
+
+func TestImportSkills_InvalidAsset_PrintsIssues(t *testing.T) {
+ src := t.TempDir()
+ writeFile(t, filepath.Join(src, ".agents", "skills", "bad", "SKILL.md"),
+ "---\nname: bad\ndescription: \n---\n\n## S\n"+strings.Repeat("x", 20))
+
+ cmd := NewCommand("")
+ cmd.SetArgs([]string{"import", "--source", src, "--dest", "", "--domains", "bad", "--dry-run"})
+ out := captureStdout(t, func() {
+ if err := cmd.Execute(); err != nil {
+ // import may return error because dest is empty; that's fine.
+ }
+ })
+ if !strings.Contains(out, "!") {
+ t.Fatalf("expected issue marker in output, got %s", out)
+ }
+}
+
+func TestImportSkills_RenderError(t *testing.T) {
+ src := t.TempDir()
+ dst := t.TempDir()
+ writeFile(t, filepath.Join(src, ".agents", "skills", "x", "SKILL.md"),
+ "---\nname: x\ndescription: d\n---\n\n## S\n"+strings.Repeat("x", 20))
+
+ old := yamlMarshalHook
+ yamlMarshalHook = func(v any) ([]byte, error) { return nil, fmt.Errorf("render failed") }
+ defer func() { yamlMarshalHook = old }()
+
+ _, err := ImportSkills(ImportOptions{SourceBase: src, DestDir: dst, IncludeDomains: []string{"x"}})
+ if err == nil || !strings.Contains(err.Error(), "render failed") {
+ t.Fatalf("expected render error, got %v", err)
+ }
+}
+
+func TestImportSkills_WriteFileError(t *testing.T) {
+ src := t.TempDir()
+ dst := t.TempDir()
+ writeFile(t, filepath.Join(src, ".agents", "skills", "x", "SKILL.md"),
+ "---\nname: x\ndescription: d\n---\n\n## S\n"+strings.Repeat("x", 20))
+
+ old := osWriteFileHook
+ osWriteFileHook = func(path string, data []byte, perm fs.FileMode) error {
+ return fmt.Errorf("write failed")
+ }
+ defer func() { osWriteFileHook = old }()
+
+ _, err := ImportSkills(ImportOptions{SourceBase: src, DestDir: dst, IncludeDomains: []string{"x"}})
+ if err == nil || !strings.Contains(err.Error(), "write failed") {
+ t.Fatalf("expected write error, got %v", err)
+ }
+}
+
+func TestImportSkills_DestDirMkdirError(t *testing.T) {
+ src := t.TempDir()
+ dst := t.TempDir()
+ writeFile(t, filepath.Join(src, ".agents", "skills", "x", "SKILL.md"),
+ "---\nname: x\ndescription: d\n---\n\n## S\n"+strings.Repeat("x", 20))
+
+ oldMkdir := osMkdirAllHook
+ oldWrite := osWriteFileHook
+ defer func() { osMkdirAllHook = oldMkdir; osWriteFileHook = oldWrite }()
+
+ calls := 0
+ osMkdirAllHook = func(path string, perm fs.FileMode) error {
+ calls++
+ if calls == 2 { // second call is for the skill subdir
+ return fmt.Errorf("subdir mkdir failed")
+ }
+ return os.MkdirAll(path, perm)
+ }
+ osWriteFileHook = func(path string, data []byte, perm fs.FileMode) error {
+ return os.WriteFile(path, data, perm)
+ }
+
+ _, err := ImportSkills(ImportOptions{SourceBase: src, DestDir: dst, IncludeDomains: []string{"x"}})
+ if err == nil || !strings.Contains(err.Error(), "subdir mkdir failed") {
+ t.Fatalf("expected subdir mkdir error, got %v", err)
+ }
+}
+
+func TestLoadSourceSkills_NoSkills(t *testing.T) {
+ base := t.TempDir()
+ _, err := loadSourceSkills(base)
+ if err == nil || !strings.Contains(err.Error(), "no skills found") {
+ t.Fatalf("expected no skills error, got %v", err)
+ }
+}
+
+func TestLoadSourceSkills_LoadDirError(t *testing.T) {
+ base := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(base, ".agents", "skills"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ old := walkDirHook
+ walkDirHook = func(root string, fn fs.WalkDirFunc) error {
+ return fmt.Errorf("walk failed")
+ }
+ defer func() { walkDirHook = old }()
+
+ _, err := loadSourceSkills(base)
+ if err == nil || !strings.Contains(err.Error(), "walk failed") {
+ t.Fatalf("expected walk error, got %v", err)
+ }
+}
+
+func TestMatchesAnyDomain_False(t *testing.T) {
+ a := &Asset{Name: "python", Domain: "python"}
+ if matchesAnyDomain(a, []string{"go", "rust"}) {
+ t.Fatal("expected no domain match")
+ }
+}
+
+func TestDedupeByName_Duplicate(t *testing.T) {
+ list := []*Asset{
+ {Name: "Same"},
+ {Name: "same"},
+ }
+ out := dedupeByName(list)
+ if len(out) != 1 {
+ t.Fatalf("expected 1, got %d", len(out))
+ }
+}
+
+func TestHasErrors_True(t *testing.T) {
+ if !hasErrors([]Issue{{Level: "error"}, {Level: "warn"}}) {
+ t.Fatal("expected hasErrors true")
+ }
+}
+
+// ─── CLI ─────────────────────────────────────────────────────────────────
+
+func TestNewCommand_List(t *testing.T) {
+ base := t.TempDir()
+ writeFile(t, filepath.Join(base, "agents", "a.md"), "---\nname: a\ndescription: d\norigin: ECC\n---\n")
+ writeFile(t, filepath.Join(base, "commands", "c.md"), "---\nname: c\ndescription: d\n---\n")
+
+ cmd := NewCommand(base)
+ cmd.SetArgs([]string{"list"})
+ out := captureStdout(t, func() {
+ if err := cmd.Execute(); err != nil {
+ t.Fatal(err)
+ }
+ })
+ if !strings.Contains(out, "agent") || !strings.Contains(out, "command") {
+ t.Fatalf("expected list output, got %s", out)
+ }
+}
+
+func TestNewCommand_ListWithKind(t *testing.T) {
+ base := t.TempDir()
+ writeFile(t, filepath.Join(base, "agents", "a.md"), "---\nname: a\ndescription: d\n---\n")
+ writeFile(t, filepath.Join(base, "commands", "c.md"), "---\nname: c\ndescription: d\n---\n")
+
+ cmd := NewCommand(base)
+ cmd.SetArgs([]string{"list", "agent"})
+ out := captureStdout(t, func() {
+ if err := cmd.Execute(); err != nil {
+ t.Fatal(err)
+ }
+ })
+ if !strings.Contains(out, "agent") {
+ t.Fatalf("expected agent in output, got %s", out)
+ }
+ if strings.Contains(out, "command") {
+ t.Fatalf("did not expect command in output, got %s", out)
+ }
+}
+
+func TestNewCommand_Validate(t *testing.T) {
+ base := t.TempDir()
+ writeFile(t, filepath.Join(base, "agents", "a.md"),
+ "---\nname: a\ndescription: d\nmodel: m\ntools: [t]\n---\n"+strings.Repeat("x", 20))
+
+ cmd := NewCommand(base)
+ cmd.SetArgs([]string{"validate"})
+ out := captureStdout(t, func() {
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("validate returned error: %v", err)
+ }
+ })
+ if !strings.Contains(out, "0 issues") {
+ t.Fatalf("expected no issues, got %s", out)
+ }
+}
+
+func TestNewCommand_ValidateErrors(t *testing.T) {
+ base := t.TempDir()
+ writeFile(t, filepath.Join(base, "agents", "a.md"), "---\nname: a\ndescription: \n---\n")
+
+ cmd := NewCommand(base)
+ cmd.SetArgs([]string{"validate"})
+ out := captureStdout(t, func() {
+ if err := cmd.Execute(); err == nil {
+ t.Fatal("expected validation error")
+ }
+ })
+ if !strings.Contains(out, "error") {
+ t.Fatalf("expected error output, got %s", out)
+ }
+}
+
+func TestNewCommand_Show(t *testing.T) {
+ base := t.TempDir()
+ writeFile(t, filepath.Join(base, "agents", "a.md"), "---\nname: a\ndescription: d\n---\nbody text")
+
+ cmd := NewCommand(base)
+ cmd.SetArgs([]string{"show", "agent", "a"})
+ out := captureStdout(t, func() {
+ if err := cmd.Execute(); err != nil {
+ t.Fatal(err)
+ }
+ })
+ if !strings.Contains(out, "body text") {
+ t.Fatalf("expected body text, got %s", out)
+ }
+}
+
+func TestNewCommand_ShowNotFound(t *testing.T) {
+ base := t.TempDir()
+ cmd := NewCommand(base)
+ cmd.SetArgs([]string{"show", "agent", "missing"})
+ if err := cmd.Execute(); err == nil {
+ t.Fatal("expected not found error")
+ }
+}
+
+func TestNewCommand_Import(t *testing.T) {
+ src := t.TempDir()
+ dst := t.TempDir()
+ writeFile(t, filepath.Join(src, ".agents", "skills", "go-patterns", "SKILL.md"),
+ "---\nname: go-patterns\ndescription: d\n---\n\n## S\n"+strings.Repeat("x", 20))
+
+ cmd := NewCommand("")
+ cmd.SetArgs([]string{
+ "import", "--source", src, "--dest", dst,
+ "--domains", "go", "--dry-run",
+ })
+ out := captureStdout(t, func() {
+ if err := cmd.Execute(); err != nil {
+ t.Fatal(err)
+ }
+ })
+ if !strings.Contains(out, "go-patterns") {
+ t.Fatalf("expected go-patterns in output, got %s", out)
+ }
+ if !strings.Contains(out, "dry run") {
+ t.Fatalf("expected dry run message, got %s", out)
+ }
+}
+
+func TestNewCommand_ImportError(t *testing.T) {
+ cmd := NewCommand("")
+ cmd.SetArgs([]string{"import", "--source", "", "--dest", "dst", "--domains", "go"})
+ if err := cmd.Execute(); err == nil {
+ t.Fatal("expected import error")
+ }
+}
+
+func TestNewCommand_ImportAll(t *testing.T) {
+ src := t.TempDir()
+ writeFile(t, filepath.Join(src, ".agents", "skills", "go-patterns", "SKILL.md"),
+ "---\nname: go-patterns\ndescription: d\n---\n\n## S\n"+strings.Repeat("x", 20))
+
+ cmd := NewCommand("")
+ cmd.SetArgs([]string{
+ "import", "--source", src, "--dest", "", "--all", "--dry-run",
+ })
+ out := captureStdout(t, func() {
+ if err := cmd.Execute(); err != nil {
+ t.Fatal(err)
+ }
+ })
+ if !strings.Contains(out, "go-patterns") {
+ t.Fatalf("expected go-patterns in output, got %s", out)
+ }
+}
+
+func TestNewCommand_LoadError(t *testing.T) {
+ base := t.TempDir()
+ // Create a file so LoadStandardLayout calls LoadDir (not just skipping).
+ writeFile(t, filepath.Join(base, "agents", "a.md"), "---\nname: a\ndescription: d\n---\n")
+ cmd := NewCommand(base)
+ cmd.SetArgs([]string{"list", "agent"})
+ old := walkDirHook
+ walkDirHook = func(root string, fn fs.WalkDirFunc) error {
+ return fmt.Errorf("walk failed")
+ }
+ defer func() { walkDirHook = old }()
+
+ if err := cmd.Execute(); err == nil {
+ t.Fatal("expected load error")
+ }
+}
+
+func TestNewCommand_ValidateLoadError(t *testing.T) {
+ base := t.TempDir()
+ writeFile(t, filepath.Join(base, "agents", "a.md"), "---\nname: a\ndescription: d\n---\n")
+ cmd := NewCommand(base)
+ cmd.SetArgs([]string{"validate"})
+ old := walkDirHook
+ walkDirHook = func(root string, fn fs.WalkDirFunc) error {
+ return fmt.Errorf("walk failed")
+ }
+ defer func() { walkDirHook = old }()
+
+ if err := cmd.Execute(); err == nil {
+ t.Fatal("expected validate load error")
+ }
+}
+
+func TestNewCommand_ShowLoadError(t *testing.T) {
+ base := t.TempDir()
+ writeFile(t, filepath.Join(base, "agents", "a.md"), "---\nname: a\ndescription: d\n---\n")
+ cmd := NewCommand(base)
+ cmd.SetArgs([]string{"show", "agent", "a"})
+ old := walkDirHook
+ walkDirHook = func(root string, fn fs.WalkDirFunc) error {
+ return fmt.Errorf("walk failed")
+ }
+ defer func() { walkDirHook = old }()
+
+ if err := cmd.Execute(); err == nil {
+ t.Fatal("expected show load error")
+ }
+}
+
+func TestNewCommand_ImportWithDefaults(t *testing.T) {
+ src := t.TempDir()
+ writeFile(t, filepath.Join(src, ".agents", "skills", "go-patterns", "SKILL.md"),
+ "---\nname: go-patterns\ndescription: d\n---\n\n## S\n"+strings.Repeat("x", 20))
+
+ cmd := NewCommand("")
+ cmd.SetArgs([]string{"import", "--source", src, "--dest", "", "--dry-run"})
+ out := captureStdout(t, func() {
+ if err := cmd.Execute(); err != nil {
+ t.Fatal(err)
+ }
+ })
+ if !strings.Contains(out, "go-patterns") {
+ t.Fatalf("expected go-patterns in output, got %s", out)
+ }
+}
+
+func TestYAMLUnmarshal(t *testing.T) {
+ // Exercise the yaml.Unmarshal error path in a controlled way.
+ var a Asset
+ if err := yaml.Unmarshal([]byte("name: [bad"), &a); err == nil {
+ t.Fatal("expected yaml error")
+ }
+}
diff --git a/cmd/sin-code/internal/assets/importer.go b/cmd/sin-code/internal/assets/importer.go
index 262f7165..68e3733b 100644
--- a/cmd/sin-code/internal/assets/importer.go
+++ b/cmd/sin-code/internal/assets/importer.go
@@ -13,6 +13,13 @@ import (
"strings"
)
+// package-level hooks to make import error branches testable.
+var (
+ osMkdirAllHook = os.MkdirAll
+ osWriteFileHook = os.WriteFile
+ loadSourceSkillsHook = loadSourceSkills
+)
+
// ImportOptions controls a skill harvest from a vendored source repo.
type ImportOptions struct {
SourceBase string // root of the cloned source repo (e.g. ./vendor/ecc)
@@ -48,7 +55,7 @@ func DefaultExclusions() []string {
// filters, stamps attribution, and writes survivors to DestDir.
func ImportSkills(opts ImportOptions) (ImportReport, error) {
var rep ImportReport
- skills, err := loadSourceSkills(opts.SourceBase)
+ skills, err := loadSourceSkillsHook(opts.SourceBase)
if err != nil {
return rep, err
}
@@ -56,7 +63,7 @@ func ImportSkills(opts ImportOptions) (ImportReport, error) {
includeDom := opts.IncludeDomains
if !opts.DryRun && opts.DestDir != "" {
- if err := os.MkdirAll(opts.DestDir, 0o755); err != nil {
+ if err := osMkdirAllHook(opts.DestDir, 0o755); err != nil {
return rep, err
}
}
@@ -94,10 +101,10 @@ func ImportSkills(opts ImportOptions) (ImportReport, error) {
return rep, err
}
dst := filepath.Join(opts.DestDir, a.Name, "SKILL.md")
- if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+ if err := osMkdirAllHook(filepath.Dir(dst), 0o755); err != nil {
return rep, err
}
- if err := os.WriteFile(dst, data, 0o644); err != nil {
+ if err := osWriteFileHook(dst, data, 0o644); err != nil {
return rep, err
}
}
diff --git a/cmd/sin-code/internal/assets/loader.go b/cmd/sin-code/internal/assets/loader.go
index 427814bd..4a2f5976 100644
--- a/cmd/sin-code/internal/assets/loader.go
+++ b/cmd/sin-code/internal/assets/loader.go
@@ -11,12 +11,18 @@ import (
"strings"
)
+// package-level hooks to make filesystem error branches testable.
+var (
+ walkDirHook = filepath.WalkDir
+ osReadFileHook = os.ReadFile
+)
+
// LoadDir walks a directory tree and loads every *.md file as the
// given kind. Skills are expected as
//SKILL.md; agents
// and commands as flat *.md.
func LoadDir(root string, kind Kind) ([]*Asset, error) {
var out []*Asset
- err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ err := walkDirHook(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
@@ -30,7 +36,7 @@ func LoadDir(root string, kind Kind) ([]*Asset, error) {
if kind == KindSkill && name != "skill.md" {
return nil // skills live in SKILL.md only
}
- data, err := os.ReadFile(path)
+ data, err := osReadFileHook(path)
if err != nil {
return err
}
diff --git a/cmd/sin-code/internal/commands/commands.go b/cmd/sin-code/internal/commands/commands.go
index 1c613584..362e63a3 100644
--- a/cmd/sin-code/internal/commands/commands.go
+++ b/cmd/sin-code/internal/commands/commands.go
@@ -12,6 +12,9 @@ import (
"strings"
)
+// osReadFileHook is swapable for testing the Load error branch.
+var osReadFileHook = os.ReadFile
+
type Command struct {
Name string
Description string
@@ -36,7 +39,7 @@ func Load(projectDir string) (map[string]Command, error) {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
}
- raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
+ raw, err := osReadFileHook(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
diff --git a/cmd/sin-code/internal/commands/coverage_test.go b/cmd/sin-code/internal/commands/coverage_test.go
new file mode 100644
index 00000000..e846794b
--- /dev/null
+++ b/cmd/sin-code/internal/commands/coverage_test.go
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: MIT
+// Purpose: additional coverage tests to reach 100% statement coverage.
+package commands
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestLoadReadFileError(t *testing.T) {
+ dir := t.TempDir()
+ cmdDir := filepath.Join(dir, ".sin", "commands")
+ if err := os.MkdirAll(cmdDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ p := filepath.Join(cmdDir, "broken.md")
+ if err := os.WriteFile(p, []byte("body"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(p, 0o000); err != nil {
+ t.Fatal(err)
+ }
+ defer os.Chmod(p, 0o644)
+
+ old := osReadFileHook
+ osReadFileHook = func(name string) ([]byte, error) {
+ if name == p {
+ return nil, os.ErrPermission
+ }
+ return os.ReadFile(name)
+ }
+ defer func() { osReadFileHook = old }()
+
+ cmds, err := Load(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := cmds["broken"]; ok {
+ t.Fatal("broken command should have been skipped")
+ }
+}
+
+func TestParseFrontmatterNoColon(t *testing.T) {
+ raw := "---\nno colon here\ndescription: valid\n---\nbody"
+ c := parse("x", raw)
+ if c.Description != "valid" {
+ t.Fatalf("expected description 'valid', got %q", c.Description)
+ }
+ if c.Template != "body" {
+ t.Fatalf("expected template 'body', got %q", c.Template)
+ }
+}
diff --git a/cmd/sin-code/internal/dataset/dataset.go b/cmd/sin-code/internal/dataset/dataset.go
index 1b9437cb..27c120fe 100644
--- a/cmd/sin-code/internal/dataset/dataset.go
+++ b/cmd/sin-code/internal/dataset/dataset.go
@@ -26,6 +26,15 @@ import (
"time"
)
+// Filesystem hooks for deterministic error-path testing (kept
+// package-local so the public API is unchanged). Set only in tests.
+var (
+ filepathAbs = filepath.Abs
+ osReadFile = os.ReadFile
+ osStat = os.Stat
+ filepathWalkDir = filepath.WalkDir
+)
+
// Dataset is a deployable, versioned collection of TestCase items.
// Version is opaque — JSON schema treats it as a free-form string so it
// can also hold semver-with-prefix strings ("v1.0.0", "1.0.0-rc1").
@@ -80,11 +89,11 @@ func LoadDataset(path string) (*Dataset, error) {
if path == "" {
return nil, errors.New("dataset: empty path")
}
- abs, err := filepath.Abs(path)
+ abs, err := filepathAbs(path)
if err != nil {
return nil, fmt.Errorf("dataset: absolute path for %q: %w", path, err)
}
- raw, err := os.ReadFile(abs)
+ raw, err := osReadFile(abs)
if err != nil {
return nil, fmt.Errorf("dataset: read %q: %w", abs, err)
}
@@ -178,17 +187,17 @@ func ListDatasets(dir string) ([]string, error) {
if dir == "" {
dir = "evals"
}
- abs, err := filepath.Abs(dir)
+ abs, err := filepathAbs(dir)
if err != nil {
return nil, fmt.Errorf("dataset: absolute path for %q: %w", dir, err)
}
- if info, err := os.Stat(abs); err != nil {
+ if info, err := osStat(abs); err != nil {
return nil, fmt.Errorf("dataset: stat %q: %w", abs, err)
} else if !info.IsDir() {
return nil, fmt.Errorf("dataset: %q is not a directory", abs)
}
var out []string
- err = filepath.WalkDir(abs, func(path string, d fs.DirEntry, walkErr error) error {
+ err = filepathWalkDir(abs, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
diff --git a/cmd/sin-code/internal/dataset/dataset_test.go b/cmd/sin-code/internal/dataset/dataset_test.go
index ecdbe6e9..c4e28b46 100644
--- a/cmd/sin-code/internal/dataset/dataset_test.go
+++ b/cmd/sin-code/internal/dataset/dataset_test.go
@@ -3,10 +3,18 @@
package dataset
import (
+ "context"
+ "errors"
+ "io/fs"
"os"
"path/filepath"
"strings"
"testing"
+ "time"
+
+ "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/agentloop"
+ "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/session"
+ "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/verify"
)
func writeTemp(t *testing.T, body string) string {
@@ -169,3 +177,345 @@ func TestListDatasets_MissingDir(t *testing.T) {
t.Fatal("expected error for missing dir")
}
}
+
+func TestLoadDataset_EmptyPath(t *testing.T) {
+ _, err := LoadDataset("")
+ if err == nil {
+ t.Fatal("expected error for empty path")
+ }
+}
+
+func TestLoadDataset_ParseError(t *testing.T) {
+ p := writeTemp(t, "not json")
+ _, err := LoadDataset(p)
+ if err == nil || !strings.Contains(err.Error(), "parse") {
+ t.Fatalf("expected parse error, got %v", err)
+ }
+}
+
+func TestLoadDataset_ValidateError(t *testing.T) {
+ p := writeTemp(t, `{"version":"1.0","test_cases":[{"id":"x","prompt":"y"}]}`)
+ _, err := LoadDataset(p)
+ if err == nil || !strings.Contains(err.Error(), "validate") {
+ t.Fatalf("expected validate error, got %v", err)
+ }
+}
+
+func TestLoadDataset_AbsError(t *testing.T) {
+ orig := filepathAbs
+ filepathAbs = func(path string) (string, error) { return "", errors.New("boom") }
+ defer func() { filepathAbs = orig }()
+ _, err := LoadDataset("x.json")
+ if err == nil || !strings.Contains(err.Error(), "absolute path") {
+ t.Fatalf("expected absolute path error, got %v", err)
+ }
+}
+
+func TestListDatasets_EmptyDirDefaultsToEvals(t *testing.T) {
+ root := t.TempDir()
+ evals := filepath.Join(root, "evals")
+ if err := os.MkdirAll(evals, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(evals, "a.json"), []byte(goodJSON), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ t.Chdir(root)
+ files, err := ListDatasets("")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(files) != 1 {
+ t.Fatalf("expected 1 file, got %v", files)
+ }
+}
+
+func TestListDatasets_AbsError(t *testing.T) {
+ orig := filepathAbs
+ filepathAbs = func(path string) (string, error) { return "", errors.New("boom") }
+ defer func() { filepathAbs = orig }()
+ _, err := ListDatasets("evals")
+ if err == nil || !strings.Contains(err.Error(), "absolute path") {
+ t.Fatalf("expected absolute path error, got %v", err)
+ }
+}
+
+func TestListDatasets_NotDir(t *testing.T) {
+ p := filepath.Join(t.TempDir(), "f.json")
+ if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ _, err := ListDatasets(p)
+ if err == nil || !strings.Contains(err.Error(), "not a directory") {
+ t.Fatalf("expected not-directory error, got %v", err)
+ }
+}
+
+func TestListDatasets_WalkError(t *testing.T) {
+ orig := filepathWalkDir
+ filepathWalkDir = func(root string, fn fs.WalkDirFunc) error {
+ return errors.New("walk failed")
+ }
+ defer func() { filepathWalkDir = orig }()
+ _, err := ListDatasets(t.TempDir())
+ if err == nil || !strings.Contains(err.Error(), "walk") {
+ t.Fatalf("expected walk error, got %v", err)
+ }
+}
+
+func TestListDatasets_WalkCallbackError(t *testing.T) {
+ orig := filepathWalkDir
+ filepathWalkDir = func(root string, fn fs.WalkDirFunc) error {
+ return fn(root, nil, errors.New("callback err"))
+ }
+ defer func() { filepathWalkDir = orig }()
+ _, err := ListDatasets(t.TempDir())
+ if err == nil || !strings.Contains(err.Error(), "callback err") {
+ t.Fatalf("expected callback error, got %v", err)
+ }
+}
+
+func TestDatasetValidate_MissingID(t *testing.T) {
+ ds := Dataset{Name: "x", Version: "1.0", TestCases: []TestCase{{Prompt: "p"}}}
+ err := ds.Validate()
+ if err == nil || !strings.Contains(err.Error(), "id is required") {
+ t.Fatalf("expected id error, got %v", err)
+ }
+}
+
+func TestDatasetValidate_NegativeMaxTurns(t *testing.T) {
+ ds := Dataset{Name: "x", Version: "1.0", TestCases: []TestCase{{ID: "a", Prompt: "p", Constraints: Constraints{MaxTurns: -1}}}}
+ err := ds.Validate()
+ if err == nil || !strings.Contains(err.Error(), "max_turns") {
+ t.Fatalf("expected max_turns error, got %v", err)
+ }
+}
+
+func TestDatasetValidate_NegativeMaxTokens(t *testing.T) {
+ ds := Dataset{Name: "x", Version: "1.0", TestCases: []TestCase{{ID: "a", Prompt: "p", Constraints: Constraints{MaxTokens: -1}}}}
+ err := ds.Validate()
+ if err == nil || !strings.Contains(err.Error(), "max_tokens") {
+ t.Fatalf("expected max_tokens error, got %v", err)
+ }
+}
+
+func inMemoryStore(t *testing.T) *session.Store {
+ s, err := session.Open(":memory:")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = s.Close() })
+ return s
+}
+
+func loopingRunner(t *testing.T, override func(context.Context, *session.Session, string) (*agentloop.Result, error)) *Runner {
+ loop := &agentloop.Loop{RunOverride: override}
+ r, err := NewRunner(RunnerConfig{}, loop, inMemoryStore(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return r
+}
+
+func TestNewRunner_NilLoop(t *testing.T) {
+ _, err := NewRunner(RunnerConfig{}, nil, inMemoryStore(t))
+ if err == nil || !strings.Contains(err.Error(), "loop is nil") {
+ t.Fatalf("expected nil loop error, got %v", err)
+ }
+}
+
+func TestNewRunner_NilStore(t *testing.T) {
+ loop := &agentloop.Loop{RunOverride: func(context.Context, *session.Session, string) (*agentloop.Result, error) {
+ return &agentloop.Result{}, nil
+ }}
+ _, err := NewRunner(RunnerConfig{}, loop, nil)
+ if err == nil || !strings.Contains(err.Error(), "session store is nil") {
+ t.Fatalf("expected nil store error, got %v", err)
+ }
+}
+
+func TestNewRunner_Defaults(t *testing.T) {
+ loop := &agentloop.Loop{RunOverride: func(context.Context, *session.Session, string) (*agentloop.Result, error) {
+ return &agentloop.Result{}, nil
+ }}
+ r, err := NewRunner(RunnerConfig{}, loop, inMemoryStore(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if r.cfg.MaxConcurrency != 1 || r.cfg.TimeoutPerCase != 5*time.Minute || r.cfg.HeadlessMode != true {
+ t.Fatalf("unexpected defaults: %+v", r.cfg)
+ }
+ if r.cfg.VerifyMode != string(verify.ModePoC) {
+ t.Fatalf("expected verify mode %q, got %q", verify.ModePoC, r.cfg.VerifyMode)
+ }
+}
+
+func TestRunDataset_Nil(t *testing.T) {
+ r := loopingRunner(t, func(context.Context, *session.Session, string) (*agentloop.Result, error) {
+ return &agentloop.Result{}, nil
+ })
+ _, err := r.RunDataset(context.Background(), nil)
+ if err == nil || !strings.Contains(err.Error(), "nil dataset") {
+ t.Fatalf("expected nil dataset error, got %v", err)
+ }
+}
+
+func TestRunDataset_Happy(t *testing.T) {
+ r := loopingRunner(t, func(context.Context, *session.Session, string) (*agentloop.Result, error) {
+ return &agentloop.Result{Summary: "done", Verified: true, Turns: 3}, nil
+ })
+ ds := &Dataset{Name: "x", Version: "1", TestCases: []TestCase{{ID: "a", Prompt: "p"}}}
+ res, err := r.RunDataset(context.Background(), ds)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(res) != 1 || !res[0].Success || !res[0].VerifyPassed {
+ t.Fatalf("unexpected result: %+v", res[0])
+ }
+}
+
+func TestRunCase_ErrorNoResult(t *testing.T) {
+ r := loopingRunner(t, func(context.Context, *session.Session, string) (*agentloop.Result, error) {
+ return nil, errors.New("boom")
+ })
+ res := r.RunCase(context.Background(), &TestCase{ID: "a", Prompt: "p"})
+ if res.Error == "" || res.Success {
+ t.Fatalf("unexpected result: %+v", res)
+ }
+}
+
+func TestRunCase_ErrorWithResult(t *testing.T) {
+ r := loopingRunner(t, func(context.Context, *session.Session, string) (*agentloop.Result, error) {
+ return &agentloop.Result{Summary: "partial", Verified: true, Turns: 2}, errors.New("boom")
+ })
+ res := r.RunCase(context.Background(), &TestCase{ID: "a", Prompt: "p"})
+ if res.Error != "boom" || res.Turns != 2 || !res.VerifyPassed || res.FinalOutput != "partial" {
+ t.Fatalf("unexpected result: %+v", res)
+ }
+}
+
+func TestRunCase_Timeout(t *testing.T) {
+ r := loopingRunner(t, func(ctx context.Context, s *session.Session, p string) (*agentloop.Result, error) {
+ <-ctx.Done()
+ return nil, ctx.Err()
+ })
+ // cfg.TimeoutPerCase defaults to 5m; override to 1ns.
+ r.cfg.TimeoutPerCase = 1 * time.Nanosecond
+ res := r.RunCase(context.Background(), &TestCase{ID: "a", Prompt: "p"})
+ if !res.TimedOut {
+ t.Fatalf("expected timeout, got %+v", res)
+ }
+}
+
+func TestRunCase_SessionError(t *testing.T) {
+ loop := &agentloop.Loop{RunOverride: func(context.Context, *session.Session, string) (*agentloop.Result, error) {
+ return &agentloop.Result{}, nil
+ }}
+ store, err := session.Open(filepath.Join(t.TempDir(), "sessions.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ store.Close()
+ r, err := NewRunner(RunnerConfig{}, loop, store)
+ if err != nil {
+ t.Fatal(err)
+ }
+ res := r.RunCase(context.Background(), &TestCase{ID: "a", Prompt: "p"})
+ if res.Error == "" || res.SessionID != "" {
+ t.Fatalf("expected session error, got %+v", res)
+ }
+}
+
+func TestRunCase_ValidConstraintTimeout(t *testing.T) {
+ r := loopingRunner(t, func(context.Context, *session.Session, string) (*agentloop.Result, error) {
+ return &agentloop.Result{Summary: "ok", Verified: true}, nil
+ })
+ res := r.RunCase(context.Background(), &TestCase{ID: "a", Prompt: "p", Constraints: Constraints{Timeout: "50ms"}})
+ if !res.Success {
+ t.Fatalf("unexpected result: %+v", res)
+ }
+}
+
+func TestRunCase_InvalidAndZeroTimeouts(t *testing.T) {
+ loop := &agentloop.Loop{RunOverride: func(context.Context, *session.Session, string) (*agentloop.Result, error) {
+ return &agentloop.Result{Summary: "ok", Verified: true}, nil
+ }}
+ store := inMemoryStore(t)
+ // Negative TimeoutPerCase is preserved by NewRunner, letting the last-resort 30s cap run.
+ r, err := NewRunner(RunnerConfig{TimeoutPerCase: -1 * time.Second}, loop, store)
+ if err != nil {
+ t.Fatal(err)
+ }
+ res := r.RunCase(context.Background(), &TestCase{ID: "a", Prompt: "p", Constraints: Constraints{Timeout: "not-a-duration"}})
+ if !res.Success {
+ t.Fatalf("unexpected result: %+v", res)
+ }
+ // Zero/negative constraint duration also hits the fallback cap.
+ res2 := r.RunCase(context.Background(), &TestCase{ID: "b", Prompt: "p", Constraints: Constraints{Timeout: "0s"}})
+ if !res2.Success {
+ t.Fatalf("unexpected result: %+v", res2)
+ }
+}
+
+func TestApplyRules(t *testing.T) {
+ r := loopingRunner(t, nil)
+ tc := &TestCase{ID: "a", Prompt: "p"}
+ res := RunResult{Success: true}
+ out := r.applyRules(tc, &res)
+ if !out.Success {
+ t.Fatalf("empty rules should keep success: %+v", out)
+ }
+
+ tc2 := &TestCase{ID: "b", Prompt: "p", Constraints: Constraints{MaxTurns: 2}}
+ out2 := r.applyRules(tc2, &RunResult{Success: true, Turns: 3})
+ if out2.Success || !strings.Contains(out2.Error, "turns=3 > max_turns=2") {
+ t.Fatalf("max_turns violation not recorded: %+v", out2)
+ }
+
+ tc3 := &TestCase{ID: "c", Prompt: "p", Constraints: Constraints{RequireVerify: true}}
+ out3 := r.applyRules(tc3, &RunResult{Success: true, VerifyPassed: false})
+ if out3.Success || !strings.Contains(out3.Error, "verify not passed") {
+ t.Fatalf("require_verify violation not recorded: %+v", out3)
+ }
+
+ tc4 := &TestCase{ID: "d", Prompt: "p", Constraints: Constraints{MustUseTools: []string{"Read"}}}
+ out4 := r.applyRules(tc4, &RunResult{Success: true, ToolsUsed: []string{"Write"}})
+ if out4.Success || !strings.Contains(out4.Error, "missing required tool: Read") {
+ t.Fatalf("must_use violation not recorded: %+v", out4)
+ }
+
+ tc5 := &TestCase{ID: "e", Prompt: "p", Constraints: Constraints{ForbiddenTools: []string{"Write"}}}
+ out5 := r.applyRules(tc5, &RunResult{Success: true, ToolsUsed: []string{"Write"}})
+ if out5.Success || !strings.Contains(out5.Error, "used forbidden tool: Write") {
+ t.Fatalf("forbidden tool violation not recorded: %+v", out5)
+ }
+
+ tc6 := &TestCase{ID: "f", Prompt: "p", Expected: Expected{OutputContains: []string{"hello"}}}
+ out6 := r.applyRules(tc6, &RunResult{Success: true, FinalOutput: "goodbye"})
+ if out6.Success || !strings.Contains(out6.Error, "missing output keyword: hello") {
+ t.Fatalf("output_contains violation not recorded: %+v", out6)
+ }
+
+ tc7 := &TestCase{ID: "g", Prompt: "p", Expected: Expected{OutputAvoids: []string{"bad"}}}
+ out7 := r.applyRules(tc7, &RunResult{Success: true, FinalOutput: "bad idea"})
+ if out7.Success || !strings.Contains(out7.Error, "contains forbidden output keyword: bad") {
+ t.Fatalf("output_avoids violation not recorded: %+v", out7)
+ }
+
+ // Error already set: applyRules should not overwrite it.
+ tc8 := &TestCase{ID: "h", Prompt: "p", Constraints: Constraints{MaxTurns: 1}}
+ res8 := RunResult{Success: true, Turns: 2, Error: "existing"}
+ out8 := r.applyRules(tc8, &res8)
+ if out8.Success || out8.Error != "existing" {
+ t.Fatalf("expected error not overwritten, got %+v", out8)
+ }
+}
+
+func TestContains(t *testing.T) {
+ if contains([]string{"a", "b"}, "a") != true {
+ t.Fatal("expected contains a")
+ }
+ if contains([]string{"a", "b"}, "c") != false {
+ t.Fatal("expected not contains c")
+ }
+}
diff --git a/cmd/sin-code/internal/dispatch/dispatch_test.go b/cmd/sin-code/internal/dispatch/dispatch_test.go
index 2ef9037e..8c084561 100644
--- a/cmd/sin-code/internal/dispatch/dispatch_test.go
+++ b/cmd/sin-code/internal/dispatch/dispatch_test.go
@@ -97,3 +97,132 @@ func TestDelegateToAgent(t *testing.T) {
t.Fatalf("wrong invocation: %+v", runner.inv)
}
}
+
+func TestResolveAgent_Unknown(t *testing.T) {
+ _, err := ResolveAgent(testReg(), "no-one", "task")
+ if err == nil || !strings.Contains(err.Error(), "unknown agent") {
+ t.Fatalf("expected unknown agent error, got %v", err)
+ }
+}
+
+func TestSelectAndResolveAgent_NoMatch(t *testing.T) {
+ _, err := SelectAndResolveAgent(testReg(), assets.Context{Domain: "unknown"}, "task")
+ if err == nil || !strings.Contains(err.Error(), "no agent matched") {
+ t.Fatalf("expected no-match error, got %v", err)
+ }
+}
+
+func TestParseArgs_FlagVariants(t *testing.T) {
+ // --key=value and a boolean flag at end (no value).
+ a := ParseArgs(`pos --flag=val --bool`)
+ if len(a.Positional) != 1 || a.Positional[0] != "pos" {
+ t.Fatalf("positional wrong: %v", a.Positional)
+ }
+ if a.Flags["flag"] != "val" || a.Flags["bool"] != "true" {
+ t.Fatalf("flags wrong: %v", a.Flags)
+ }
+}
+
+func TestParseArgs_QuotedPositional(t *testing.T) {
+ a := ParseArgs(`pos "quoted"`)
+ if len(a.Positional) != 2 || a.Positional[1] != "quoted" {
+ t.Fatalf("positional wrong: %v", a.Positional)
+ }
+}
+
+func TestSubstitute_FlagsAndPositions(t *testing.T) {
+ a := Args{Positional: []string{"a", "b"}, Flags: map[string]string{"k": "v"}, Raw: "a b"}
+ got := a.Substitute("$1 $2 $3 $ARGUMENTS ${k} $@")
+ want := "a b a b v a b"
+ if got != want {
+ t.Fatalf("substitute: got %q, want %q", got, want)
+ }
+}
+
+func TestItoa_Zero(t *testing.T) {
+ if itoa(0) != "0" {
+ t.Fatalf("itoa(0) = %q", itoa(0))
+ }
+}
+
+func TestResolveCommand_Unknown(t *testing.T) {
+ _, err := ResolveCommand(testReg(), "/unknown", "")
+ if err == nil || !strings.Contains(err.Error(), "unknown command") {
+ t.Fatalf("expected unknown command error, got %v", err)
+ }
+}
+
+func TestParseSlash_NoArgs(t *testing.T) {
+ name, args, ok := ParseSlash("/tdd")
+ if !ok || name != "tdd" || args != "" {
+ t.Fatalf("got %q %q %v", name, args, ok)
+ }
+}
+
+func TestDispatcher_NonSlash(t *testing.T) {
+ d := &Dispatcher{Reg: testReg(), Prompts: &capturingSink{}}
+ handled, err := d.Dispatch(context.Background(), "hello world")
+ if handled || err != nil {
+ t.Fatalf("expected non-slash to be unhandled: handled=%v err=%v", handled, err)
+ }
+}
+
+func TestDispatcher_ResolveError(t *testing.T) {
+ d := &Dispatcher{Reg: testReg(), Prompts: &capturingSink{}}
+ handled, err := d.Dispatch(context.Background(), "/unknown")
+ if !handled || err == nil {
+ t.Fatalf("expected command error: handled=%v err=%v", handled, err)
+ }
+}
+
+func TestDispatcher_NoPromptSink(t *testing.T) {
+ d := &Dispatcher{Reg: testReg()}
+ handled, err := d.Dispatch(context.Background(), "/tdd x")
+ if !handled || err == nil || !strings.Contains(err.Error(), "no prompt sink") {
+ t.Fatalf("expected prompt sink error: handled=%v err=%v", handled, err)
+ }
+}
+
+func TestDelegateToAgent_NoRunner(t *testing.T) {
+ d := &Dispatcher{Reg: testReg()}
+ _, err := d.DelegateToAgent(context.Background(), assets.Context{Domain: "go"}, "task")
+ if err == nil || !strings.Contains(err.Error(), "no subagent runner") {
+ t.Fatalf("expected runner error, got %v", err)
+ }
+}
+
+func TestDelegateToAgent_NoMatch(t *testing.T) {
+ d := &Dispatcher{Reg: testReg(), Agents: &stubRunner{}}
+ _, err := d.DelegateToAgent(context.Background(), assets.Context{Domain: "unknown"}, "task")
+ if err == nil || !strings.Contains(err.Error(), "no agent matched") {
+ t.Fatalf("expected no-match error, got %v", err)
+ }
+}
+
+func TestRunNamedAgent_NoRunner(t *testing.T) {
+ d := &Dispatcher{Reg: testReg()}
+ _, err := d.RunNamedAgent(context.Background(), "go-reviewer", "task")
+ if err == nil || !strings.Contains(err.Error(), "no subagent runner") {
+ t.Fatalf("expected runner error, got %v", err)
+ }
+}
+
+func TestRunNamedAgent_Unknown(t *testing.T) {
+ d := &Dispatcher{Reg: testReg(), Agents: &stubRunner{}}
+ _, err := d.RunNamedAgent(context.Background(), "unknown", "task")
+ if err == nil || !strings.Contains(err.Error(), "unknown agent") {
+ t.Fatalf("expected unknown agent error, got %v", err)
+ }
+}
+
+func TestRunNamedAgent_Success(t *testing.T) {
+ runner := &stubRunner{}
+ d := &Dispatcher{Reg: testReg(), Agents: runner}
+ out, err := d.RunNamedAgent(context.Background(), "go-reviewer", "task")
+ if err != nil || out != "done" {
+ t.Fatalf("unexpected: %q %v", out, err)
+ }
+ if runner.inv.Name != "go-reviewer" || runner.inv.Task != "task" {
+ t.Fatalf("wrong invocation: %+v", runner.inv)
+ }
+}
diff --git a/cmd/sin-code/internal/dox/dox.go b/cmd/sin-code/internal/dox/dox.go
index 12b0d3b8..8211d71b 100644
--- a/cmd/sin-code/internal/dox/dox.go
+++ b/cmd/sin-code/internal/dox/dox.go
@@ -17,6 +17,16 @@ import (
"strings"
)
+// Filesystem hooks for deterministic error-path testing. Set only in tests.
+var (
+ fsReadFile = os.ReadFile
+ fsWriteFile = os.WriteFile
+ fsStat = os.Stat
+ fsMkdirAll = os.MkdirAll
+ fsReadDir = os.ReadDir
+ fsAbsPath = filepath.Abs
+)
+
// ── Markers ────────────────────────────────────────────────────────────
//
// The dox block lives between BeginMarker / EndMarker HTML-comment
@@ -135,11 +145,11 @@ func Build(root string) (*Node, error) {
if root == "" {
return nil, ErrEmptyPath
}
- abs, err := filepath.Abs(root)
+ abs, err := fsAbsPath(root)
if err != nil {
return nil, err
}
- info, err := os.Stat(abs)
+ info, err := fsStat(abs)
if err != nil {
return nil, err
}
@@ -150,7 +160,7 @@ func Build(root string) (*Node, error) {
}
func buildNode(dir string, parent *Node) (*Node, error) {
- entries, err := os.ReadDir(dir)
+ entries, err := fsReadDir(dir)
if err != nil {
return nil, err
}
@@ -161,7 +171,7 @@ func buildNode(dir string, parent *Node) (*Node, error) {
}
// Title: prefer frontmatter title from AGENTS.md / INDEX.md.
for _, candidate := range []string{AgentsFileName, IndexFileName, "README.md"} {
- body, err := os.ReadFile(filepath.Join(dir, candidate))
+ body, err := fsReadFile(filepath.Join(dir, candidate))
if err != nil {
continue
}
@@ -240,7 +250,7 @@ func Check(root string) ([]Finding, error) {
func checkNode(n *Node, isRoot bool, out *[]Finding) {
// Root must have an AGENTS.md.
if isRoot {
- if _, err := os.Stat(filepath.Join(n.Path, AgentsFileName)); err != nil {
+ if _, err := fsStat(filepath.Join(n.Path, AgentsFileName)); err != nil {
*out = append(*out, Finding{
Path: n.Path,
Kind: "missing-agents",
@@ -253,7 +263,7 @@ func checkNode(n *Node, isRoot bool, out *[]Finding) {
hasIndex := false
hasAgents := false
for _, name := range []string{IndexFileName, AgentsFileName} {
- if _, err := os.Stat(filepath.Join(n.Path, name)); err == nil {
+ if _, err := fsStat(filepath.Join(n.Path, name)); err == nil {
if name == IndexFileName {
hasIndex = true
}
@@ -274,7 +284,7 @@ func checkNode(n *Node, isRoot bool, out *[]Finding) {
// Scan the body of AGENTS.md / INDEX.md for TODO sentinels and
// broken markdown links.
for _, candidate := range []string{AgentsFileName, IndexFileName} {
- body, err := os.ReadFile(filepath.Join(n.Path, candidate))
+ body, err := fsReadFile(filepath.Join(n.Path, candidate))
if err != nil {
continue
}
@@ -311,7 +321,7 @@ func checkNode(n *Node, isRoot bool, out *[]Finding) {
continue
}
full := filepath.Join(n.Path, target)
- if _, err := os.Stat(full); err != nil {
+ if _, err := fsStat(full); err != nil {
*out = append(*out, Finding{
Path: filepath.Join(n.Path, candidate),
Kind: "broken-link",
@@ -419,7 +429,7 @@ func InjectRoot(agentsPath, body string) error {
// body ensure the block is visually separated from surrounding
// text in the rendered markdown.
block := BeginMarker + "\n" + body + "\n" + EndMarker + "\n"
- existing, err := os.ReadFile(agentsPath)
+ existing, err := fsReadFile(agentsPath)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
@@ -442,7 +452,7 @@ func InjectRoot(agentsPath, body string) error {
}
trimmed += "\n"
content = trimmed + block
- return os.WriteFile(agentsPath, []byte(content), DefaultFileMode)
+ return fsWriteFile(agentsPath, []byte(content), DefaultFileMode)
}
// RemoveBlock strips the dox block from `agentsPath` (no-op if the
@@ -451,7 +461,7 @@ func RemoveBlock(agentsPath string) (int, error) {
if agentsPath == "" {
return 0, ErrEmptyPath
}
- existing, err := os.ReadFile(agentsPath)
+ existing, err := fsReadFile(agentsPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return 0, nil
@@ -474,7 +484,7 @@ func RemoveBlock(agentsPath string) (int, error) {
}
removed := end - i
newContent := content[:i] + content[end:]
- if err := os.WriteFile(agentsPath, []byte(newContent), DefaultFileMode); err != nil {
+ if err := fsWriteFile(agentsPath, []byte(newContent), DefaultFileMode); err != nil {
return 0, err
}
return removed, nil
@@ -494,18 +504,18 @@ func Scaffold(parent, name, title string) (string, error) {
if parent == "" || name == "" {
return "", ErrEmptyPath
}
- absParent, err := filepath.Abs(parent)
+ absParent, err := fsAbsPath(parent)
if err != nil {
return "", err
}
- if info, err := os.Stat(absParent); err != nil || !info.IsDir() {
+ if info, err := fsStat(absParent); err != nil || !info.IsDir() {
return "", fmt.Errorf("%w: %s", ErrNotADirectory, absParent)
}
child := filepath.Join(absParent, name)
- if info, err := os.Stat(child); err == nil && info.IsDir() {
+ if info, err := fsStat(child); err == nil && info.IsDir() {
return "", fmt.Errorf("%w: %s", ErrAlreadyExists, child)
}
- if err := os.MkdirAll(child, DefaultDirMode); err != nil {
+ if err := fsMkdirAll(child, DefaultDirMode); err != nil {
return "", err
}
if title == "" {
@@ -521,7 +531,7 @@ func Scaffold(parent, name, title string) (string, error) {
if isRootSibling(absParent) {
target = AgentsFileName
}
- if err := os.WriteFile(filepath.Join(child, target), []byte(body), DefaultFileMode); err != nil {
+ if err := fsWriteFile(filepath.Join(child, target), []byte(body), DefaultFileMode); err != nil {
return "", err
}
// Register the child in the parent's index. We append (not
@@ -537,7 +547,7 @@ func Scaffold(parent, name, title string) (string, error) {
// AGENTS.md when the parent itself is a root, so nested trees stay
// pure.
func isRootSibling(parent string) bool {
- _, err := os.Stat(filepath.Join(parent, AgentsFileName))
+ _, err := fsStat(filepath.Join(parent, AgentsFileName))
return err == nil
}
@@ -558,7 +568,7 @@ func registerInParent(parent, name, title string) error {
}
indexPath := filepath.Join(parent, target)
linkNeedle := "[" + name + "](" + name + "/" + childFile + ")"
- existing, err := os.ReadFile(indexPath)
+ existing, err := fsReadFile(indexPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
// No index yet — create a minimal one with this child.
@@ -566,7 +576,7 @@ func registerInParent(parent, name, title string) error {
"# " + filepath.Base(parent) + "\n\n" +
"## Children\n\n" +
"- " + linkNeedle + " — " + title + "\n"
- return os.WriteFile(indexPath, []byte(seed), DefaultFileMode)
+ return fsWriteFile(indexPath, []byte(seed), DefaultFileMode)
}
return err
}
@@ -584,7 +594,7 @@ func registerInParent(parent, name, title string) error {
b.WriteString(" — ")
b.WriteString(title)
b.WriteByte('\n')
- return os.WriteFile(indexPath, []byte(b.String()), DefaultFileMode)
+ return fsWriteFile(indexPath, []byte(b.String()), DefaultFileMode)
}
// ── Renderer ───────────────────────────────────────────────────────────
diff --git a/cmd/sin-code/internal/dox/dox_test.go b/cmd/sin-code/internal/dox/dox_test.go
index d01c51e9..85db4b0a 100644
--- a/cmd/sin-code/internal/dox/dox_test.go
+++ b/cmd/sin-code/internal/dox/dox_test.go
@@ -8,6 +8,7 @@
package dox
import (
+ "errors"
"os"
"path/filepath"
"strings"
@@ -669,3 +670,396 @@ func TestHasStandaloneTODO(t *testing.T) {
}
}
}
+
+// ── Depth ─────────────────────────────────────────────────────────────
+
+func TestNodeDepth(t *testing.T) {
+ root := &Node{Name: "root"}
+ child := &Node{Name: "child", Parent: root}
+ grand := &Node{Name: "grand", Parent: child}
+ if root.Depth() != 0 || child.Depth() != 1 || grand.Depth() != 2 {
+ t.Fatalf("Depth values wrong")
+ }
+}
+
+// ── Build error paths ─────────────────────────────────────────────────
+
+func TestBuild_AbsError(t *testing.T) {
+ orig := fsAbsPath
+ fsAbsPath = func(path string) (string, error) { return "", errors.New("abs boom") }
+ defer func() { fsAbsPath = orig }()
+ if _, err := Build("/some/path"); err == nil || !strings.Contains(err.Error(), "abs boom") {
+ t.Fatalf("expected abs error, got %v", err)
+ }
+}
+
+func TestBuild_MissingDir(t *testing.T) {
+ if _, err := Build(filepath.Join(t.TempDir(), "missing")); err == nil {
+ t.Fatal("expected error for missing dir")
+ }
+}
+
+func TestBuild_ReadDirError(t *testing.T) {
+ orig := fsReadDir
+ fsReadDir = func(name string) ([]os.DirEntry, error) {
+ return nil, errors.New("read boom")
+ }
+ defer func() { fsReadDir = orig }()
+ if _, err := Build(t.TempDir()); err == nil || !strings.Contains(err.Error(), "read boom") {
+ t.Fatalf("expected read error, got %v", err)
+ }
+}
+
+// ── buildNode edge cases ──────────────────────────────────────────────
+
+func TestBuild_TitleReadError(t *testing.T) {
+ dir := t.TempDir()
+ // Create a symlink candidate that points nowhere so ReadFile returns an error.
+ if err := os.Symlink("/no/such/file", filepath.Join(dir, "README.md")); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Build(dir); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestBuild_DotDirSkipped(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, ".hidden"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("# Root\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ tree, err := Build(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(tree.Children) != 0 {
+ t.Fatalf("expected dot-dir skipped, got %d children", len(tree.Children))
+ }
+}
+
+func TestBuild_ChildReadDirError(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("# Root\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(dir, "child"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ orig := fsReadDir
+ fsReadDir = func(name string) ([]os.DirEntry, error) {
+ if strings.HasSuffix(name, "child") {
+ return nil, errors.New("child read boom")
+ }
+ return orig(name)
+ }
+ defer func() { fsReadDir = orig }()
+ if _, err := Build(dir); err == nil || !strings.Contains(err.Error(), "child read boom") {
+ t.Fatalf("expected child read error, got %v", err)
+ }
+}
+
+// ── Check edge cases ──────────────────────────────────────────────────
+
+func TestCheck_BuildError(t *testing.T) {
+ if _, err := Check(filepath.Join(t.TempDir(), "missing")); err == nil {
+ t.Fatal("expected build error")
+ }
+}
+
+func TestCheck_ChildWithAgentsOnly(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, AgentsFileName), []byte("# Root\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ child := filepath.Join(dir, "has-agents")
+ if err := os.MkdirAll(child, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(child, AgentsFileName), []byte("# Child\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ findings, err := Check(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, f := range findings {
+ if f.Path == child && f.Kind == "orphan" {
+ t.Fatalf("child with AGENTS.md should not be orphan: %+v", f)
+ }
+ }
+}
+
+func TestCheck_LinkVariants(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, AgentsFileName), []byte(
+ "# Root\n\n"+
+ "- [http](https://example.com)\n"+
+ "- [anchor](real.md#x)\n"+
+ "- [empty]()\n"+
+ "- [broken](missing.md)\n",
+ ), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "real.md"), []byte("x\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ findings, err := Check(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var broken []string
+ for _, f := range findings {
+ if f.Kind == "broken-link" {
+ broken = append(broken, f.Message)
+ }
+ }
+ if len(broken) != 1 || !strings.Contains(broken[0], "missing.md") {
+ t.Fatalf("expected only broken-link for missing.md, got %v", broken)
+ }
+}
+
+// ── extractMarkdownLinks variants ─────────────────────────────────────
+
+func TestExtractMarkdownLinks_Variants(t *testing.T) {
+ if got := len(extractMarkdownLinks("no brackets")); got != 0 {
+ t.Fatalf("expected 0 links, got %d", got)
+ }
+ if got := extractMarkdownLinks("[text"); len(got) != 0 {
+ t.Fatalf("expected 0 links for unclosed bracket, got %v", got)
+ }
+ if got := extractMarkdownLinks("[text]("); len(got) != 0 {
+ t.Fatalf("expected 0 links for unclosed paren, got %v", got)
+ }
+}
+
+// ── InjectRoot error paths ─────────────────────────────────────────────
+
+func TestInjectRoot_ReadError(t *testing.T) {
+ if err := InjectRoot(t.TempDir(), "body"); err == nil {
+ t.Fatal("expected error when agentsPath is a directory")
+ }
+}
+
+// ── RemoveBlock edge cases ─────────────────────────────────────────────
+
+func TestRemoveBlock_EmptyPath(t *testing.T) {
+ if _, err := RemoveBlock(""); err == nil {
+ t.Fatal("expected error for empty path")
+ }
+}
+
+func TestRemoveBlock_MissingFile(t *testing.T) {
+ n, err := RemoveBlock(filepath.Join(t.TempDir(), "AGENTS.md"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n != 0 {
+ t.Fatalf("expected 0, got %d", n)
+ }
+}
+
+func TestRemoveBlock_ReadError(t *testing.T) {
+ if _, err := RemoveBlock(t.TempDir()); err == nil {
+ t.Fatal("expected error when path is a directory")
+ }
+}
+
+func TestRemoveBlock_WriteError(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "AGENTS.md")
+ if err := InjectRoot(p, "body"); err != nil {
+ t.Fatal(err)
+ }
+ orig := fsWriteFile
+ fsWriteFile = func(name string, data []byte, perm os.FileMode) error {
+ return errors.New("write boom")
+ }
+ defer func() { fsWriteFile = orig }()
+ if _, err := RemoveBlock(p); err == nil || !strings.Contains(err.Error(), "write boom") {
+ t.Fatalf("expected write error, got %v", err)
+ }
+}
+
+func TestRemoveBlock_EndBeforeBegin(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "AGENTS.md")
+ if err := os.WriteFile(p, []byte("x"+EndMarker+BeginMarker+"y\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ n, err := RemoveBlock(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n != 0 {
+ t.Fatalf("expected 0, got %d", n)
+ }
+}
+
+func TestRemoveBlock_NoEndMarker(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "AGENTS.md")
+ if err := os.WriteFile(p, []byte(BeginMarker+"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ n, err := RemoveBlock(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n != 0 {
+ t.Fatalf("expected 0, got %d", n)
+ }
+}
+
+// ── Scaffold edge cases ───────────────────────────────────────────────
+
+func TestScaffold_EmptyPath(t *testing.T) {
+ if _, err := Scaffold("", "x", "X"); err == nil {
+ t.Fatal("expected error for empty parent")
+ }
+ if _, err := Scaffold(t.TempDir(), "", "X"); err == nil {
+ t.Fatal("expected error for empty name")
+ }
+}
+
+func TestScaffold_AbsError(t *testing.T) {
+ orig := fsAbsPath
+ fsAbsPath = func(path string) (string, error) { return "", errors.New("abs boom") }
+ defer func() { fsAbsPath = orig }()
+ if _, err := Scaffold(t.TempDir(), "x", "X"); err == nil || !strings.Contains(err.Error(), "abs boom") {
+ t.Fatalf("expected abs error, got %v", err)
+ }
+}
+
+func TestScaffold_ParentNotDir(t *testing.T) {
+ f := filepath.Join(t.TempDir(), "file")
+ if err := os.WriteFile(f, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Scaffold(f, "x", "X"); err == nil {
+ t.Fatal("expected error for non-directory parent")
+ }
+}
+
+func TestScaffold_TitleFallback(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, AgentsFileName), []byte("# Root\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ child, err := Scaffold(dir, "foo", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, _ := os.ReadFile(filepath.Join(child, AgentsFileName))
+ if !strings.Contains(string(body), "# foo") {
+ t.Fatalf("title fallback missing: %s", string(body))
+ }
+}
+
+func TestScaffold_MkdirError(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, AgentsFileName), []byte("# Root\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ orig := fsMkdirAll
+ fsMkdirAll = func(path string, perm os.FileMode) error { return errors.New("mkdir boom") }
+ defer func() { fsMkdirAll = orig }()
+ if _, err := Scaffold(dir, "foo", "Foo"); err == nil || !strings.Contains(err.Error(), "mkdir boom") {
+ t.Fatalf("expected mkdir error, got %v", err)
+ }
+}
+
+func TestScaffold_WriteChildError(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, AgentsFileName), []byte("# Root\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ orig := fsWriteFile
+ fsWriteFile = func(name string, data []byte, perm os.FileMode) error {
+ if strings.Contains(name, "AGENTS.md") {
+ return errors.New("write child boom")
+ }
+ return orig(name, data, perm)
+ }
+ defer func() { fsWriteFile = orig }()
+ if _, err := Scaffold(dir, "foo", "Foo"); err == nil || !strings.Contains(err.Error(), "write child boom") {
+ t.Fatalf("expected write child error, got %v", err)
+ }
+}
+
+func TestScaffold_RegisterError(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, AgentsFileName), []byte("# Root\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ orig := fsWriteFile
+ fsWriteFile = func(name string, data []byte, perm os.FileMode) error {
+ if strings.Contains(name, "AGENTS.md") && !strings.Contains(name, "foo") {
+ return errors.New("register boom")
+ }
+ return orig(name, data, perm)
+ }
+ defer func() { fsWriteFile = orig }()
+ path, err := Scaffold(dir, "foo", "Foo")
+ if err == nil || !strings.Contains(err.Error(), "register boom") {
+ t.Fatalf("expected register error, got %v, path=%s", err, path)
+ }
+}
+
+// ── registerInParent edge cases ─────────────────────────────────────────
+
+func TestRegisterInParent_AlreadyRegistered(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, AgentsFileName), []byte("# Root\n\n- [foo](foo/AGENTS.md) — Foo\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := registerInParent(dir, "foo", "Foo"); err != nil {
+ t.Fatal(err)
+ }
+ body, _ := os.ReadFile(filepath.Join(dir, AgentsFileName))
+ if strings.Count(string(body), "[foo]") != 1 {
+ t.Fatalf("registration duplicated: %s", string(body))
+ }
+}
+
+func TestRegisterInParent_NoNewline(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, AgentsFileName), []byte("# Root"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := registerInParent(dir, "foo", "Foo"); err != nil {
+ t.Fatal(err)
+ }
+ body, _ := os.ReadFile(filepath.Join(dir, AgentsFileName))
+ if !strings.HasSuffix(string(body), "\n") {
+ t.Fatal("expected newline appended")
+ }
+}
+
+func TestRegisterInParent_ReadError(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, AgentsFileName), []byte("# Root\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ orig := fsReadFile
+ fsReadFile = func(name string) ([]byte, error) {
+ if strings.Contains(name, AgentsFileName) {
+ return nil, errors.New("read boom")
+ }
+ return orig(name)
+ }
+ defer func() { fsReadFile = orig }()
+ if err := registerInParent(dir, "foo", "Foo"); err == nil || !strings.Contains(err.Error(), "read boom") {
+ t.Fatalf("expected read error, got %v", err)
+ }
+}
+
+// ── RenderTree error path ──────────────────────────────────────────────
+
+func TestRenderTree_BuildError(t *testing.T) {
+ if _, err := RenderTree(filepath.Join(t.TempDir(), "missing")); err == nil {
+ t.Fatal("expected build error")
+ }
+}
diff --git a/cmd/sin-code/internal/eval/judge_test.go b/cmd/sin-code/internal/eval/judge_test.go
index f87a481d..3be7a0aa 100644
--- a/cmd/sin-code/internal/eval/judge_test.go
+++ b/cmd/sin-code/internal/eval/judge_test.go
@@ -3,6 +3,7 @@
package eval
import (
+ "bytes"
"context"
"encoding/json"
"errors"
@@ -245,3 +246,152 @@ func floatNear(a, b, tol float64) bool {
}
return d <= tol
}
+
+func TestEvaluate_NilContext(t *testing.T) {
+ j, _ := NewJudge(JudgeConfig{Model: "gpt-test"}, llm.NewClient("http://x", "k"))
+ _, err := j.Evaluate(nil, Trajectory{})
+ if err == nil || !strings.Contains(err.Error(), "nil context") {
+ t.Fatalf("expected nil context error, got %v", err)
+ }
+}
+
+func TestEvaluate_NoChoices(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(chatWire{ID: "x"})
+ }))
+ defer srv.Close()
+ j, _ := NewJudge(JudgeConfig{Model: "gpt-test"}, llm.NewClient(srv.URL, "k"))
+ _, err := j.Evaluate(context.Background(), Trajectory{})
+ if err == nil || !strings.Contains(err.Error(), "no choices") {
+ t.Fatalf("expected no choices error, got %v", err)
+ }
+}
+
+func TestEvaluateBatch_Success(t *testing.T) {
+ srv := serveChat(t, `{"pass":true,"score":0.8,"reason":"ok"}`)
+ j, _ := NewJudge(JudgeConfig{Model: "gpt-test"}, llm.NewClient(srv.URL, "k"))
+ res, err := j.EvaluateBatch(context.Background(), []Trajectory{{Prompt: "a"}, {Prompt: "b"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(res) != 2 || !res[0].Pass || !res[1].Pass {
+ t.Fatalf("unexpected batch result: %+v", res)
+ }
+}
+
+func TestBuildSystemPrompt_Strict(t *testing.T) {
+ j, _ := NewJudge(JudgeConfig{Model: "m", Strict: true}, llm.NewClient("http://x", "k"))
+ prompt := j.buildSystemPrompt(Trajectory{})
+ if !strings.Contains(prompt, "STRICT MODE") {
+ t.Fatalf("expected strict clause in prompt: %s", prompt)
+ }
+}
+
+func TestBuildUserPrompt_ToolsAndCriteria(t *testing.T) {
+ j, _ := NewJudge(JudgeConfig{Model: "m"}, llm.NewClient("http://x", "k"))
+ prompt := j.buildUserPrompt(Trajectory{
+ Prompt: "x", ToolsUsed: []string{"Read", "Write"}, FinalOutput: "ok",
+ CustomCriteria: "must be fast",
+ })
+ if !strings.Contains(prompt, "Tools used: Read, Write") {
+ t.Fatalf("missing tools line: %s", prompt)
+ }
+ if !strings.Contains(prompt, "Custom criteria: must be fast") {
+ t.Fatalf("missing custom criteria: %s", prompt)
+ }
+}
+
+func TestTruncate(t *testing.T) {
+ if truncate("short", 100) != "short" {
+ t.Fatalf("short string should be unchanged")
+ }
+ long := strings.Repeat("a", 200)
+ got := truncate(long, 100)
+ if !strings.HasSuffix(got, "…(truncated)") || len(got) <= 100 {
+ t.Fatalf("unexpected truncation: len=%d", len(got))
+ }
+}
+
+// ── Metrics ───────────────────────────────────────────────────────────
+
+func TestSummarise_Empty(t *testing.T) {
+ s := Summarise(nil, 0.5)
+ if s.Total != 0 || s.PassRate != 1.0 {
+ t.Fatalf("unexpected empty summary: %+v", s)
+ }
+}
+
+func TestSummarise_NoJudgeNoDuration(t *testing.T) {
+ rs := []dataset.RunResult{
+ {TestCaseID: "a", Success: true, Turns: 1},
+ }
+ s := Summarise(rs, 0.5)
+ if s.MeanJudge != 0 || s.MeanDurMS != 0 {
+ t.Fatalf("expected zero means, got %+v", s)
+ }
+}
+
+func TestSummarise_FailureWithoutError(t *testing.T) {
+ rs := []dataset.RunResult{
+ {TestCaseID: "b", Success: false},
+ }
+ s := Summarise(rs, 0.5)
+ if len(s.Failures) != 1 || s.Failures[0] != "b" {
+ t.Fatalf("expected failure label only, got %+v", s.Failures)
+ }
+}
+
+func TestNewReport(t *testing.T) {
+ ds := &dataset.Dataset{Name: "d", Version: "1.0"}
+ start := time.Now()
+ end := start.Add(time.Minute)
+ r := NewReport(ds, "p", 0.8, []dataset.RunResult{{TestCaseID: "a", Success: true}}, start, end)
+ if r.Dataset != "d" || r.Version != "1.0" || r.Profile != "p" || r.MinRate != 0.8 || r.Started != start || r.Finished != end {
+ t.Fatalf("unexpected report fields: %+v", r)
+ }
+}
+
+func TestWriteJSON_Nil(t *testing.T) {
+ if err := WriteJSON(&bytes.Buffer{}, nil); err == nil {
+ t.Fatal("expected error for nil report")
+ }
+}
+
+func TestWriteJSON_RoundTrip(t *testing.T) {
+ r := &Report{Dataset: "d", Version: "1.0", Summary: Summary{Total: 1, Passed: 1}}
+ var buf bytes.Buffer
+ if err := WriteJSON(&buf, r); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(buf.String(), "dataset") {
+ t.Fatalf("unexpected JSON: %s", buf.String())
+ }
+}
+
+func TestBelowMinRate_Error(t *testing.T) {
+ err := &BelowMinRate{PassRate: 0.5, Minimum: 0.9}
+ if !strings.Contains(err.Error(), "50.00%") || !strings.Contains(err.Error(), "90.00%") {
+ t.Fatalf("unexpected error string: %s", err.Error())
+ }
+}
+
+func TestFormatHuman(t *testing.T) {
+ s := Summary{
+ Total: 4, Passed: 2, Failed: 2, PassRate: 0.5, MinRequired: 0.9,
+ Timeouts: 1, MeanJudge: 0.75,
+ Failures: []string{"a: boom", "b"},
+ }
+ out := FormatHuman(s)
+ for _, want := range []string{"Total: 4", "Pass Rate: 50.00%", "Timeouts: 1", "Mean judge score: 0.75", "a: boom", "b"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("output missing %q: %s", want, out)
+ }
+ }
+}
+
+func TestRoundPassRate(t *testing.T) {
+ if got := RoundPassRate(0.123456789); got != 0.1235 {
+ t.Fatalf("RoundPassRate: got %v", got)
+ }
+}
diff --git a/cmd/sin-code/internal/ghbridge/ghbridge.go b/cmd/sin-code/internal/ghbridge/ghbridge.go
index c1d63865..c4e7d529 100644
--- a/cmd/sin-code/internal/ghbridge/ghbridge.go
+++ b/cmd/sin-code/internal/ghbridge/ghbridge.go
@@ -168,7 +168,7 @@ func (b *Bridge) Execute(ctx context.Context, args []string) (string, Tier, erro
if b == nil || b.Run == nil {
return "", TierForbidden, errors.New("ghbridge: nil bridge or runner")
}
- tier, err := Classify(args)
+ tier, err := classifyFunc(args)
if err != nil {
// Defense in depth: classification failures are hard-stops.
// The runner MUST NOT be invoked.
@@ -274,6 +274,16 @@ var forbiddenTokens = map[string]bool{
"transfer": true,
}
+// classifyFunc is the runtime classifier used by Execute. It defaults
+// to Classify but can be swapped in tests to exercise defense-in-depth
+// branches that are otherwise unreachable.
+var classifyFunc = Classify
+
+// classifySkipForbiddenScan is a test seam. When true, the forbidden-token
+// scan in Classify is bypassed so the defensive verb-slot re-check can be
+// covered. Defaults to false, so production behavior is unchanged.
+var classifySkipForbiddenScan bool
+
// Classify inspects a gh arg list and returns its Tier plus an error
// describing WHY a call is rejected. The function is FAIL-CLOSED:
// unknown input → TierForbidden with an explanatory error. Callers
@@ -296,8 +306,11 @@ func Classify(args []string) (Tier, error) {
}
// Step 2: scan every position for a forbidden token. This catches
// `gh issue list delete` and `gh repo view --json api` alike.
+ // classifySkipForbiddenScan is a test seam that lets tests exercise the
+ // defensive re-check on the verb slot (line below) without weakening the
+ // real classifier. It defaults to false.
for _, a := range args {
- if forbiddenTokens[a] {
+ if !classifySkipForbiddenScan && forbiddenTokens[a] {
return TierForbidden, fmt.Errorf("ghbridge: forbidden token %q in args", a)
}
}
diff --git a/cmd/sin-code/internal/ghbridge/ghbridge_test.go b/cmd/sin-code/internal/ghbridge/ghbridge_test.go
index 2b081fce..9bb82d03 100644
--- a/cmd/sin-code/internal/ghbridge/ghbridge_test.go
+++ b/cmd/sin-code/internal/ghbridge/ghbridge_test.go
@@ -1118,3 +1118,476 @@ func parseAllResponses(t *testing.T, out *bytes.Buffer) []*jsonRPCResponse {
}
return resp
}
+
+// ── 100% coverage completion tests ─────────────────────────────────────
+
+// fakeGhDir creates a fake `gh` binary in a temp directory and returns
+// the directory. The binary is a shell script that prints "ok" for auth
+// status and echoes its arguments for everything else.
+func fakeGhDir(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ gh := filepath.Join(dir, "gh")
+ script := `#!/bin/sh
+if [ "$1" = "auth" ] && [ "$2" = "status" ]; then
+ echo "Logged in"
+ exit 0
+fi
+echo "out: $*"
+exit 0
+`
+ if err := os.WriteFile(gh, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ return dir
+}
+
+func TestExecRunnerCapturesOutput(t *testing.T) {
+ dir := fakeGhDir(t)
+ t.Setenv("PATH", dir)
+ stdout, stderr, err := ExecRunner(context.Background(), []string{"issue", "list"})
+ if err != nil {
+ t.Fatalf("ExecRunner: %v", err)
+ }
+ if !strings.Contains(stdout, "issue list") {
+ t.Errorf("stdout: %q", stdout)
+ }
+ if stderr != "" {
+ t.Errorf("stderr: %q", stderr)
+ }
+}
+
+func TestExecRunnerExitError(t *testing.T) {
+ dir := t.TempDir()
+ gh := filepath.Join(dir, "gh")
+ if err := os.WriteFile(gh, []byte("#!/bin/sh\necho err >&2; exit 1\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", dir)
+ _, stderr, err := ExecRunner(context.Background(), []string{"issue", "list"})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !strings.Contains(stderr, "err") {
+ t.Errorf("stderr: %q", stderr)
+ }
+}
+
+func TestNewServer(t *testing.T) {
+ s := NewServer()
+ if s == nil {
+ t.Fatal("NewServer returned nil")
+ }
+}
+
+func TestLazyBridgeNoGh(t *testing.T) {
+ t.Setenv("PATH", "")
+ srv := NewServerWithIO(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
+ _, err := srv.lazyBridge()
+ if err == nil {
+ t.Fatal("expected error when gh is missing")
+ }
+ // Errors are sticky.
+ _, err2 := srv.lazyBridge()
+ if err2.Error() != err.Error() {
+ t.Fatalf("sticky error mismatch: %v vs %v", err, err2)
+ }
+}
+
+func TestLazyBridgeSuccess(t *testing.T) {
+ dir := fakeGhDir(t)
+ t.Setenv("PATH", dir)
+ srv := NewServerWithIO(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
+ b, err := srv.lazyBridge()
+ if err != nil {
+ t.Fatalf("lazyBridge: %v", err)
+ }
+ if b == nil {
+ t.Fatal("lazyBridge returned nil bridge")
+ }
+}
+
+func TestHealthNoGh(t *testing.T) {
+ b := New()
+ t.Setenv("PATH", "")
+ if err := b.Health(context.Background()); err == nil {
+ t.Fatal("expected error when gh not in PATH")
+ }
+}
+
+func TestHealthSuccessWithFakeGh(t *testing.T) {
+ dir := fakeGhDir(t)
+ t.Setenv("PATH", dir)
+ b := New()
+ if err := b.Health(context.Background()); err != nil {
+ t.Fatalf("Health: %v", err)
+ }
+}
+
+func TestExecuteStdoutFallbackOnError(t *testing.T) {
+ fake := newFakeRunner(fakeResponse{stdout: "stdout hint", stderr: "", err: errors.New("exit 1")})
+ b := NewWithRunner(runForBridge(fake), time.Second)
+ _, _, err := b.Execute(context.Background(), []string{"issue", "list"})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !strings.Contains(err.Error(), "stdout hint") {
+ t.Errorf("error should surface stdout when stderr empty: %v", err)
+ }
+}
+
+func TestExecuteForbiddenUnreachableBranch(t *testing.T) {
+ old := classifyFunc
+ classifyFunc = func(args []string) (Tier, error) {
+ return TierForbidden, nil
+ }
+ defer func() { classifyFunc = old }()
+ fake := newFakeRunner()
+ b := NewWithRunner(runForBridge(fake), time.Second)
+ out, tier, err := b.Execute(context.Background(), []string{"anything"})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if tier != TierForbidden {
+ t.Errorf("tier: %s", tier)
+ }
+ if out != "" {
+ t.Errorf("stdout: %q", out)
+ }
+ if fake.callCount() != 0 {
+ t.Fatal("runner should not be invoked")
+ }
+}
+
+func TestMCPConfigPathUserHomeDirError(t *testing.T) {
+ t.Setenv("SIN_CODE_HOME", "")
+ old := userHomeDir
+ userHomeDir = func() (string, error) {
+ return "", errors.New("no home")
+ }
+ defer func() { userHomeDir = old }()
+ p := MCPConfigPath()
+ if !strings.HasSuffix(p, ".sin-code-home/mcp.json") {
+ t.Errorf("fallback path: %q", p)
+ }
+}
+
+func TestRegisterMCPExecutableError(t *testing.T) {
+ old := osExecutable
+ osExecutable = func() (string, error) {
+ return "", errors.New("no exe")
+ }
+ defer func() { osExecutable = old }()
+ _, err := RegisterMCP(filepath.Join(t.TempDir(), "mcp.json"))
+ if err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestWriteJSONAtomicMkdirAllError(t *testing.T) {
+ dir := t.TempDir()
+ // Create a file where the parent directory is expected.
+ badParent := filepath.Join(dir, "parent")
+ if err := os.WriteFile(badParent, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(badParent, "mcp.json")
+ if err := writeJSONAtomic(path, map[string]any{"x": 1}); err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestWriteJSONAtomicCreateTempError(t *testing.T) {
+ dir := t.TempDir()
+ // Create a subdirectory with no write permission so CreateTemp fails.
+ ro := filepath.Join(dir, "ro")
+ if err := os.Mkdir(ro, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(ro, 0o000); err != nil {
+ t.Fatal(err)
+ }
+ defer os.Chmod(ro, 0o755)
+ path := filepath.Join(ro, "mcp.json")
+ if err := writeJSONAtomic(path, map[string]any{"x": 1}); err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestWriteJSONAtomicRenameError(t *testing.T) {
+ dir := t.TempDir()
+ // Make the target path a directory so rename fails.
+ path := filepath.Join(dir, "mcp.json")
+ if err := os.Mkdir(path, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeJSONAtomic(path, map[string]any{"x": 1}); err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestResultMarshalError(t *testing.T) {
+ old := jsonMarshal
+ jsonMarshal = func(v any) ([]byte, error) {
+ return nil, errors.New("marshal boom")
+ }
+ defer func() { jsonMarshal = old }()
+ s := NewServerWithIO(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
+ req := &jsonRPCRequest{JSONRPC: "2.0", ID: mustRawID("1")}
+ resp := s.result(req, map[string]any{"x": 1})
+ if resp.Error == nil {
+ t.Fatal("expected JSON-RPC error when marshal fails")
+ }
+}
+
+func TestMustMarshalError(t *testing.T) {
+ req := &jsonRPCRequest{JSONRPC: "2.0", ID: mustRawID("1")}
+ resp := mustMarshal(req, toolResult{Content: []toolContent{{Type: "text", Text: "x"}}})
+ if string(resp) == "" {
+ t.Fatal("expected result for valid input")
+ }
+ old := jsonMarshal
+ jsonMarshal = func(v any) ([]byte, error) {
+ return nil, errors.New("marshal boom")
+ }
+ defer func() { jsonMarshal = old }()
+ bad := mustMarshal(req, toolResult{Content: []toolContent{{Type: "text", Text: "x"}}})
+ if string(bad) != `{"content":[],"isError":true}` {
+ t.Errorf("fallback: %q", string(bad))
+ }
+}
+
+func TestCallHealthBridgeInitFailure(t *testing.T) {
+ in := &bytes.Buffer{}
+ out := &bytes.Buffer{}
+ errW := &bytes.Buffer{}
+ srv := NewServerWithIO(in, out, errW)
+ t.Setenv("PATH", "")
+
+ writeReq(t, in, jsonRPCRequest{
+ JSONRPC: "2.0",
+ ID: mustRawID("1"),
+ Method: "tools/call",
+ Params: mustMarshalParams(t, "gh_health", nil),
+ })
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ if err := srv.Serve(ctx); err != nil && !errors.Is(err, io.EOF) {
+ t.Fatalf("Serve: %v", err)
+ }
+ resp := firstResponse(t, out)
+ var result toolResult
+ if err := json.Unmarshal(resp.Result, &result); err != nil {
+ t.Fatalf("result: %v", err)
+ }
+ if !result.IsError {
+ t.Fatal("expected isError=true")
+ }
+}
+
+func TestDispatchBridgeInitFailure(t *testing.T) {
+ in := &bytes.Buffer{}
+ out := &bytes.Buffer{}
+ errW := &bytes.Buffer{}
+ srv := NewServerWithIO(in, out, errW)
+ t.Setenv("PATH", "")
+
+ writeReq(t, in, jsonRPCRequest{
+ JSONRPC: "2.0",
+ ID: mustRawID("1"),
+ Method: "tools/call",
+ Params: mustMarshalParams(t, "gh_query", map[string]any{"args": []string{"issue", "list"}}),
+ })
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ if err := srv.Serve(ctx); err != nil && !errors.Is(err, io.EOF) {
+ t.Fatalf("Serve: %v", err)
+ }
+ resp := firstResponse(t, out)
+ var result toolResult
+ if err := json.Unmarshal(resp.Result, &result); err != nil {
+ t.Fatalf("result: %v", err)
+ }
+ if !result.IsError {
+ t.Fatal("expected isError=true")
+ }
+ if !strings.Contains(result.Content[0].Text, "bridge init failed") {
+ t.Errorf("error content: %q", result.Content[0].Text)
+ }
+}
+
+func TestServeScannerError(t *testing.T) {
+ in := &errReader{err: errors.New("boom")}
+ out := &bytes.Buffer{}
+ srv := NewServerWithIO(in, out, &bytes.Buffer{})
+ if err := srv.Serve(context.Background()); err == nil {
+ t.Fatal("expected scanner error")
+ }
+}
+
+func TestServeEncodeError(t *testing.T) {
+ in := &bytes.Buffer{}
+ // Write a valid request, but the response cannot be encoded.
+ writeReq(t, in, jsonRPCRequest{JSONRPC: "2.0", ID: mustRawID("1"), Method: "ping"})
+ // Replace stdout with a writer that fails on Encode.
+ srv := NewServerWithIO(in, &errWriter{err: errors.New("encode boom")}, &bytes.Buffer{})
+ if err := srv.Serve(context.Background()); err == nil {
+ t.Fatal("expected encode error")
+ }
+}
+
+func TestClassifyForbiddenVerbRecheck(t *testing.T) {
+ classifySkipForbiddenScan = true
+ defer func() { classifySkipForbiddenScan = false }()
+ _, err := Classify([]string{"issue", "delete"})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !strings.Contains(err.Error(), "forbidden verb") {
+ t.Errorf("expected forbidden verb error, got: %v", err)
+ }
+}
+
+func TestHealthErrorWithEmptyStreams(t *testing.T) {
+ fake := newFakeRunner(fakeResponse{stdout: "", stderr: "", err: errors.New("auth failed")})
+ b := NewWithRunner(runForBridge(fake), time.Second)
+ err := b.Health(context.Background())
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !strings.Contains(err.Error(), "auth failed") {
+ t.Errorf("expected error to fall back to err.Error(), got: %v", err)
+ }
+}
+
+func TestServeContextCancelled(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ srv := NewServerWithIO(strings.NewReader("line1\n"), &bytes.Buffer{}, &bytes.Buffer{})
+ if err := srv.Serve(ctx); err != context.Canceled {
+ t.Fatalf("expected context.Canceled, got %v", err)
+ }
+}
+
+func TestExecuteErrorWithEmptyStreams(t *testing.T) {
+ fake := newFakeRunner(fakeResponse{stdout: "", stderr: "", err: errors.New("auth failed")})
+ b := NewWithRunner(runForBridge(fake), time.Second)
+ _, _, err := b.Execute(context.Background(), []string{"issue", "list"})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !strings.Contains(err.Error(), "auth failed") {
+ t.Errorf("expected error to fall back to runErr.Error(), got: %v", err)
+ }
+}
+
+func TestServeEmptyLine(t *testing.T) {
+ in := &bytes.Buffer{}
+ out := &bytes.Buffer{}
+ // Empty line then a ping.
+ in.WriteString("\n")
+ writeReq(t, in, jsonRPCRequest{JSONRPC: "2.0", ID: mustRawID("1"), Method: "ping"})
+ srv := NewServerWithIO(in, out, &bytes.Buffer{})
+ if err := srv.Serve(context.Background()); err != nil {
+ t.Fatalf("Serve: %v", err)
+ }
+ resp := firstResponse(t, out)
+ if resp.Error != nil {
+ t.Fatalf("unexpected error: %v", resp.Error)
+ }
+}
+
+func TestDispatchNotificationInitialized(t *testing.T) {
+ s := NewServerWithIO(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
+ req := &jsonRPCRequest{JSONRPC: "2.0", ID: mustRawID("1"), Method: "notifications/initialized"}
+ if resp := s.dispatch(context.Background(), req); resp != nil {
+ t.Fatal("expected nil response for notification")
+ }
+}
+
+func TestDispatchNoDeadlineAndExecuteError(t *testing.T) {
+ in := &bytes.Buffer{}
+ out := &bytes.Buffer{}
+ errW := &bytes.Buffer{}
+ srv := NewServerWithIO(in, out, errW)
+ fake := newFakeRunner(fakeResponse{stderr: "boom", err: errors.New("exit 1")})
+ srv.bridgeOnce.Do(func() {
+ srv.bridge = NewWithRunner(runForBridge(fake), time.Second)
+ })
+
+ writeReq(t, in, jsonRPCRequest{
+ JSONRPC: "2.0",
+ ID: mustRawID("1"),
+ Method: "tools/call",
+ Params: mustMarshalParams(t, "gh_execute", map[string]any{"args": []string{"issue", "create", "--title", "x"}}),
+ })
+ // Use a context without a deadline to exercise the DefaultExecuteTimeout branch.
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ if err := srv.Serve(ctx); err != nil && !errors.Is(err, io.EOF) {
+ t.Fatalf("Serve: %v", err)
+ }
+ resp := firstResponse(t, out)
+ var result toolResult
+ if err := json.Unmarshal(resp.Result, &result); err != nil {
+ t.Fatalf("result: %v", err)
+ }
+ if !result.IsError {
+ t.Fatal("expected isError=true")
+ }
+ if !strings.Contains(result.Content[0].Text, "boom") {
+ t.Errorf("error content: %q", result.Content[0].Text)
+ }
+}
+
+func TestWriteJSONAtomicMarshalError(t *testing.T) {
+ old := jsonMarshal
+ jsonMarshal = func(v any) ([]byte, error) {
+ return nil, errors.New("marshal boom")
+ }
+ defer func() { jsonMarshal = old }()
+ if err := writeJSONAtomic(filepath.Join(t.TempDir(), "x.json"), 1); err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestWriteJSONAtomicCopyError(t *testing.T) {
+ old := ioCopy
+ ioCopy = func(dst io.Writer, src io.Reader) (int64, error) {
+ return 0, errors.New("copy boom")
+ }
+ defer func() { ioCopy = old }()
+ if err := writeJSONAtomic(filepath.Join(t.TempDir(), "x.json"), map[string]any{"x": 1}); err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestWriteJSONAtomicCloseError(t *testing.T) {
+ old := closeFile
+ closeFile = func(f *os.File) error {
+ return errors.New("close boom")
+ }
+ defer func() { closeFile = old }()
+ if err := writeJSONAtomic(filepath.Join(t.TempDir(), "x.json"), map[string]any{"x": 1}); err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+// errReader returns a fixed error.
+type errReader struct {
+ err error
+}
+
+func (e *errReader) Read([]byte) (int, error) {
+ return 0, e.err
+}
+
+// errWriter returns a fixed error.
+type errWriter struct {
+ err error
+}
+
+func (e *errWriter) Write([]byte) (int, error) {
+ return 0, e.err
+}
+
diff --git a/cmd/sin-code/internal/ghbridge/mcpserver.go b/cmd/sin-code/internal/ghbridge/mcpserver.go
index 58dc986f..d23857df 100644
--- a/cmd/sin-code/internal/ghbridge/mcpserver.go
+++ b/cmd/sin-code/internal/ghbridge/mcpserver.go
@@ -200,8 +200,11 @@ func (s *Server) dispatch(ctx context.Context, req *jsonRPCRequest) *jsonRPCResp
}
}
+// jsonMarshal is a test seam around json.Marshal.
+var jsonMarshal = json.Marshal
+
func (s *Server) result(req *jsonRPCRequest, v any) *jsonRPCResponse {
- data, err := json.Marshal(v)
+ data, err := jsonMarshal(v)
if err != nil {
return &jsonRPCResponse{
JSONRPC: "2.0",
@@ -335,7 +338,7 @@ func dispatch(s *Server, ctx context.Context, req *jsonRPCRequest, tool string,
// shape), we fall back to a JSON-RPC error so the call still closes
// cleanly.
func mustMarshal(req *jsonRPCRequest, v toolResult) json.RawMessage {
- data, err := json.Marshal(v)
+ data, err := jsonMarshal(v)
if err != nil {
// Unreachable in practice — the shape is static. We return a
// valid (if empty) result so the call does not hang.
@@ -399,6 +402,9 @@ func (s *Server) toolList() []toolSpec {
// ── RegisterMCP ───────────────────────────────────────────────────────
+// userHomeDir is a test seam around os.UserHomeDir.
+var userHomeDir = os.UserHomeDir
+
// MCPConfigPath is where the gh-bridge MCP server is registered.
// Matches the convention used by vane.MCPConfigPath() and
// superpowers.MCPConfigPath() so a single $SIN_CODE_HOME override
@@ -407,7 +413,7 @@ func MCPConfigPath() string {
if v := os.Getenv("SIN_CODE_HOME"); v != "" {
return filepath.Join(v, "mcp.json")
}
- if h, err := os.UserHomeDir(); err == nil {
+ if h, err := userHomeDir(); err == nil {
return filepath.Join(h, ".local", "share", "sin-code", "mcp.json")
}
return filepath.Join(".", ".sin-code-home", "mcp.json")
@@ -417,11 +423,14 @@ func MCPConfigPath() string {
// MCPConfigPath() if empty). Idempotent: existing entries with the
// same command + args are left untouched. Preserves every other key
// in the file (notably pre-existing "superpowers", "vane" entries).
+// osExecutable is a test seam around os.Executable.
+var osExecutable = os.Executable
+
func RegisterMCP(mcpPath string) (string, error) {
if mcpPath == "" {
mcpPath = MCPConfigPath()
}
- exe, err := os.Executable()
+ exe, err := osExecutable()
if err != nil {
return mcpPath, fmt.Errorf("ghbridge: resolve executable: %w", err)
}
@@ -451,11 +460,17 @@ func RegisterMCP(mcpPath string) (string, error) {
return mcpPath, writeJSONAtomic(mcpPath, cfg)
}
+// ioCopy is a test seam around io.Copy.
+var ioCopy = io.Copy
+
+// closeFile is a test seam around (*os.File).Close.
+var closeFile = (*os.File).Close
+
// writeJSONAtomic marshals v and writes to path via a temp-file +
// rename so a crash mid-write cannot corrupt the existing file. Parent
// directories are created on demand. Mirrors vane.writeJSONAtomic.
func writeJSONAtomic(path string, v any) error {
- data, err := json.MarshalIndent(v, "", " ")
+ data, err := jsonMarshal(v)
if err != nil {
return err
}
@@ -468,12 +483,13 @@ func writeJSONAtomic(path string, v any) error {
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
- if _, err := io.Copy(tmp, strings.NewReader(string(data)+"\n")); err != nil {
- tmp.Close()
+ if _, err := ioCopy(tmp, strings.NewReader(string(data)+"\n")); err != nil {
+ closeFile(tmp)
return err
}
- if err := tmp.Close(); err != nil {
+ if err := closeFile(tmp); err != nil {
return err
}
return os.Rename(tmpName, path)
}
+
diff --git a/cmd/sin-code/internal/goalcontract/baseline_test.go b/cmd/sin-code/internal/goalcontract/baseline_test.go
index e9a195d3..ea9bb072 100644
--- a/cmd/sin-code/internal/goalcontract/baseline_test.go
+++ b/cmd/sin-code/internal/goalcontract/baseline_test.go
@@ -301,3 +301,15 @@ func gitCommit(t *testing.T, ws, msg string) {
t.Fatalf("git commit: %v: %s", err, out)
}
}
+
+func TestJoinWorkspace(t *testing.T) {
+ if got := joinWorkspace("", "x"); got != "x" {
+ t.Errorf("empty workspace: %q", got)
+ }
+ if got := joinWorkspace("/ws", "x"); got != "/ws/x" {
+ t.Errorf("no trailing slash: %q", got)
+ }
+ if got := joinWorkspace("/ws/", "x"); got != "/ws/x" {
+ t.Errorf("trailing slash: %q", got)
+ }
+}
diff --git a/cmd/sin-code/internal/goalcontract/goalcontract.go b/cmd/sin-code/internal/goalcontract/goalcontract.go
index fa218b83..3bc043ae 100644
--- a/cmd/sin-code/internal/goalcontract/goalcontract.go
+++ b/cmd/sin-code/internal/goalcontract/goalcontract.go
@@ -46,13 +46,16 @@ func (c *GoalContract) IsEmpty() bool {
c.MaxFilesChanged == 0 && c.MaxLinesChanged == 0
}
+// jsonMarshal is a test seam around json.Marshal.
+var jsonMarshal = json.Marshal
+
// Marshal serializes the contract to a compact JSON string for persistence in
// the goal queue. An empty contract marshals to "".
func (c *GoalContract) Marshal() (string, error) {
if c.IsEmpty() {
return "", nil
}
- b, err := json.Marshal(c)
+ b, err := jsonMarshal(c)
if err != nil {
return "", fmt.Errorf("goalcontract: marshal: %w", err)
}
diff --git a/cmd/sin-code/internal/goalcontract/goalcontract_test.go b/cmd/sin-code/internal/goalcontract/goalcontract_test.go
index 015777c8..22c05475 100644
--- a/cmd/sin-code/internal/goalcontract/goalcontract_test.go
+++ b/cmd/sin-code/internal/goalcontract/goalcontract_test.go
@@ -4,6 +4,7 @@
package goalcontract
import (
+ "errors"
"os"
"path/filepath"
"testing"
@@ -170,3 +171,51 @@ func TestResolveMissingContractFile(t *testing.T) {
t.Fatal("expected error for missing contract file")
}
}
+
+func TestMarshalError(t *testing.T) {
+ old := jsonMarshal
+ jsonMarshal = func(v any) ([]byte, error) {
+ return nil, errors.New("marshal boom")
+ }
+ defer func() { jsonMarshal = old }()
+ c := &GoalContract{SemanticCriteria: []string{"x"}}
+ if _, err := c.Marshal(); err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestResolveContractFileMaxLinesChanged(t *testing.T) {
+ ws := t.TempDir()
+ file := filepath.Join(ws, "contract.json")
+ raw := `{"semantic_criteria":["from file"],"max_files_changed":3,"max_lines_changed":99}`
+ if err := os.WriteFile(file, []byte(raw), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ c, err := Resolve(ResolveOptions{
+ Workspace: ws,
+ ContractFile: "contract.json",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.MaxFilesChanged != 3 {
+ t.Errorf("max_files_changed: %d", c.MaxFilesChanged)
+ }
+ if c.MaxLinesChanged != 99 {
+ t.Errorf("max_lines_changed: %d", c.MaxLinesChanged)
+ }
+}
+
+func TestResolveInvalidContractFile(t *testing.T) {
+ ws := t.TempDir()
+ file := filepath.Join(ws, "contract.json")
+ if err := os.WriteFile(file, []byte("not json"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Resolve(ResolveOptions{
+ Workspace: ws,
+ ContractFile: "contract.json",
+ }); err == nil {
+ t.Fatal("expected error for invalid contract file")
+ }
+}
diff --git a/cmd/sin-code/internal/health/coverage_test.go b/cmd/sin-code/internal/health/coverage_test.go
new file mode 100644
index 00000000..e38f2e99
--- /dev/null
+++ b/cmd/sin-code/internal/health/coverage_test.go
@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: MIT
+// Purpose: additional coverage tests to reach 100% statement coverage.
+package health
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestCheckerVersion(t *testing.T) {
+ c := NewChecker("v1.2.3")
+ if got := c.Version(); got != "v1.2.3" {
+ t.Fatalf("Version: want v1.2.3, got %q", got)
+ }
+}
+
+func TestCheckDegraded(t *testing.T) {
+ c := NewChecker("v")
+ c.RegisterCheck("degraded-check", func(ctx context.Context) Check {
+ return Check{Status: StatusDegraded, Message: "slow"}
+ })
+ resp := c.Check(context.Background())
+ if resp.Status != StatusDegraded {
+ t.Fatalf("want degraded, got %s", resp.Status)
+ }
+}
+
+func TestReadinessHandlerUnhealthy(t *testing.T) {
+ c := NewChecker("v")
+ c.RegisterCheck("bad", func(ctx context.Context) Check {
+ return Check{Status: StatusUnhealthy, Message: "down"}
+ })
+
+ req := httptest.NewRequest("GET", "/ready", nil)
+ w := httptest.NewRecorder()
+
+ ReadinessHandler(c).ServeHTTP(w, req)
+
+ if w.Code != http.StatusServiceUnavailable {
+ t.Fatalf("want 503, got %d", w.Code)
+ }
+ var body map[string]string
+ if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if body["status"] != "not ready" {
+ t.Fatalf("unexpected body: %v", body)
+ }
+}
diff --git a/cmd/sin-code/internal/hooks/hooks_test.go b/cmd/sin-code/internal/hooks/hooks_test.go
index 5272896e..262e3204 100644
--- a/cmd/sin-code/internal/hooks/hooks_test.go
+++ b/cmd/sin-code/internal/hooks/hooks_test.go
@@ -4,6 +4,8 @@ package hooks
import (
"context"
+ "net/http"
+ "net/http/httptest"
"testing"
)
@@ -55,6 +57,16 @@ func TestFailingHookDegradesToWarning(t *testing.T) {
}
}
+func TestCommandHookSuccess(t *testing.T) {
+ e := New([]Hook{
+ {Event: "session.start", Type: "command", Command: `echo "ok"`},
+ })
+ res := e.Fire(context.Background(), Payload{Event: SessionStart})
+ if res.Blocked {
+ t.Fatal("exit 0 must not block")
+ }
+}
+
func TestPromptHookCollects(t *testing.T) {
e := New([]Hook{
{Event: "session.start", Type: "prompt", Text: "first"},
@@ -92,6 +104,36 @@ func TestWebhookFiresWithoutBlocking(t *testing.T) {
}
}
+func TestWebhookSuccess(t *testing.T) {
+ called := false
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ called = true
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer srv.Close()
+
+ e := New([]Hook{
+ {Event: "task.complete", Type: "webhook", URL: srv.URL},
+ })
+ res := e.Fire(context.Background(), Payload{Event: TaskComplete})
+ if res.Blocked {
+ t.Fatal("webhook must never block")
+ }
+ if !called {
+ t.Fatal("webhook server was not called")
+ }
+}
+
+func TestWebhookInvalidURL(t *testing.T) {
+ e := New([]Hook{
+ {Event: "task.complete", Type: "webhook", URL: "://invalid"},
+ })
+ res := e.Fire(context.Background(), Payload{Event: TaskComplete})
+ if res.Blocked {
+ t.Fatal("invalid webhook URL must not block")
+ }
+}
+
func TestFirstBlockWins(t *testing.T) {
e := New([]Hook{
{Event: "push.pre", Type: "command", Command: `echo a; exit 2`},
diff --git a/cmd/sin-code/internal/llm/llm_test.go b/cmd/sin-code/internal/llm/llm_test.go
index 5f7095d6..8be21085 100644
--- a/cmd/sin-code/internal/llm/llm_test.go
+++ b/cmd/sin-code/internal/llm/llm_test.go
@@ -6,6 +6,7 @@ package llm
import (
"context"
"encoding/json"
+ "errors"
"io"
"net/http"
"net/http/httptest"
@@ -262,3 +263,93 @@ func TestClientChatContextCancel(t *testing.T) {
t.Fatal("expected context error")
}
}
+
+func TestBreakerStats(t *testing.T) {
+ if NewClient("https://x", "k").BreakerStats() == nil {
+ t.Fatal("expected non-nil stats for NewClient")
+ }
+ var c *Client
+ if c.BreakerStats() != nil {
+ t.Fatal("nil client should return nil stats")
+ }
+ c = &Client{}
+ if c.BreakerStats() != nil {
+ t.Fatal("client without breaker should return nil stats")
+ }
+}
+
+func TestClientChatRecorderError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{
+ "id": "x", "object": "chat.completion", "created": 1, "model": "m",
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
+ "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
+ }`))
+ }))
+ defer srv.Close()
+
+ c := NewClient(srv.URL, "k")
+ c.Recorder = &fakeRecorder{failOn: 1}
+ _, err := c.Chat(context.Background(), ChatRequest{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestRandomSessionIDFallback(t *testing.T) {
+ old := randRead
+ randRead = func(b []byte) (int, error) {
+ return 0, errors.New("rand fail")
+ }
+ defer func() { randRead = old }()
+ if got := randomSessionID(); got != "session-fallback" {
+ t.Errorf("expected fallback, got %q", got)
+ }
+}
+
+func TestChatMarshalError(t *testing.T) {
+ old := jsonMarshal
+ jsonMarshal = func(v any) ([]byte, error) {
+ return nil, errors.New("marshal boom")
+ }
+ defer func() { jsonMarshal = old }()
+ c := NewClient("https://x", "k")
+ _, err := c.Chat(context.Background(), ChatRequest{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestChatInvalidBaseURL(t *testing.T) {
+ c := NewClient("http://[invalid", "k")
+ _, err := c.Chat(context.Background(), ChatRequest{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestResolveEmptyModelInvalidBaseURL(t *testing.T) {
+ c := NewClient("http://[invalid", "k")
+ _, err := c.Chat(context.Background(), ChatRequest{Model: "", Messages: []Message{{Role: "user", Content: "x"}}})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestResolveEmptyModelDoError(t *testing.T) {
+ c := NewClient("https://example.com", "k")
+ c.HTTP = &http.Client{Transport: &errorRoundTripper{err: errors.New("net fail")}}
+ _, err := c.Chat(context.Background(), ChatRequest{Model: "", Messages: []Message{{Role: "user", Content: "x"}}})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+type errorRoundTripper struct {
+ err error
+}
+
+func (e *errorRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
+ return nil, e.err
+}
diff --git a/cmd/sin-code/internal/llm/provider.go b/cmd/sin-code/internal/llm/provider.go
index 773ec3d9..60093219 100644
--- a/cmd/sin-code/internal/llm/provider.go
+++ b/cmd/sin-code/internal/llm/provider.go
@@ -97,6 +97,9 @@ func (c *Client) BreakerStats() *circuitbreaker.Stats {
return &s
}
+// jsonMarshal is a test seam around json.Marshal.
+var jsonMarshal = json.Marshal
+
func (c *Client) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
// Belt-and-suspenders: if the caller left Model empty, try to discover a
// default. Order of preference: explicit env override -> /v1/models probe
@@ -109,7 +112,7 @@ func (c *Client) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, erro
}
req.Model = resolved
}
- body, err := json.Marshal(req)
+ body, err := jsonMarshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
diff --git a/cmd/sin-code/internal/llm/providers_test.go b/cmd/sin-code/internal/llm/providers_test.go
index de0b6529..ed3fc444 100644
--- a/cmd/sin-code/internal/llm/providers_test.go
+++ b/cmd/sin-code/internal/llm/providers_test.go
@@ -175,3 +175,12 @@ func TestProviderFromConfigTimeout(t *testing.T) {
func TestProviderAPICallTimeout(t *testing.T) {
_ = os.Getenv("DUMMY")
}
+
+func TestProviderFromConfigMissingBaseURL(t *testing.T) {
+ // "custom" provider has no base URL; without env override it should error.
+ t.Setenv("SIN_LLM_BASE_URL", "")
+ _, err := ProviderFromConfig("custom", "", "", "", 0)
+ if err == nil {
+ t.Fatal("expected error for missing base URL")
+ }
+}
diff --git a/cmd/sin-code/internal/llm/recorder.go b/cmd/sin-code/internal/llm/recorder.go
index bbc90baa..be1bae8a 100644
--- a/cmd/sin-code/internal/llm/recorder.go
+++ b/cmd/sin-code/internal/llm/recorder.go
@@ -19,13 +19,16 @@ import (
"sync"
)
+// randRead is a test seam around crypto/rand.Read.
+var randRead = rand.Read
+
// randomSessionID returns a hex-encoded 64-bit random session id.
// Generated once per process; cached for the lifetime of the
// binary so every LLM call within the same process shares the
// default id.
func randomSessionID() string {
var b [8]byte
- if _, err := rand.Read(b[:]); err != nil {
+ if _, err := randRead(b[:]); err != nil {
// crypto/rand should never fail on a sane OS, but if it
// does we fall back to a fixed sentinel. The Recorder
// contract is best-effort; a missing session id is
diff --git a/cmd/sin-code/internal/logger/logger_test.go b/cmd/sin-code/internal/logger/logger_test.go
index de5bc21b..4864f5bd 100644
--- a/cmd/sin-code/internal/logger/logger_test.go
+++ b/cmd/sin-code/internal/logger/logger_test.go
@@ -4,6 +4,7 @@ package logger
import (
"bytes"
"encoding/json"
+ "os"
"strings"
"testing"
)
@@ -89,3 +90,50 @@ func TestLoggerTimeFormat(t *testing.T) {
t.Errorf("expected RFC3339 format, got %q", entry.Time)
}
}
+
+func TestDefault(t *testing.T) {
+ l := Default()
+ if l == nil {
+ t.Fatal("Default() returned nil")
+ }
+}
+
+func TestLevelStringUnknown(t *testing.T) {
+ if got := Level(99).String(); got != "UNKNOWN" {
+ t.Errorf("expected UNKNOWN, got %q", got)
+ }
+}
+
+func TestLevelStrings(t *testing.T) {
+ for level, want := range map[Level]string{
+ LevelDebug: "DEBUG",
+ LevelInfo: "INFO",
+ LevelWarn: "WARN",
+ LevelError: "ERROR",
+ } {
+ if got := level.String(); got != want {
+ t.Errorf("Level(%d).String() = %q, want %q", level, got, want)
+ }
+ }
+}
+
+func TestGetOutputNil(t *testing.T) {
+ l := &Logger{}
+ if got := l.getOutput(); got != os.Stderr {
+ t.Errorf("expected os.Stderr for nil output, got %v", got)
+ }
+}
+
+func TestLogMarshalError(t *testing.T) {
+ var buf bytes.Buffer
+ SetOutput(&buf)
+ defer SetOutput(nil)
+
+ SetLevel(LevelInfo)
+ Info("marshal error", map[string]any{"ch": make(chan int)})
+
+ output := buf.String()
+ if !strings.Contains(output, "logger marshal failed") {
+ t.Errorf("expected marshal failure log, got %q", output)
+ }
+}