454 lines
11 KiB
Go
454 lines
11 KiB
Go
|
|
package cmd
|
||
|
|
|
||
|
|
import (
|
||
|
|
"errors"
|
||
|
|
"os"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"github.com/spf13/cobra"
|
||
|
|
"gmgauthier.com/grokkit/internal/grok"
|
||
|
|
)
|
||
|
|
|
||
|
|
// mockStreamer records calls made to it and returns a canned response.
|
||
|
|
type mockStreamer struct {
|
||
|
|
response string
|
||
|
|
calls int
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *mockStreamer) Stream(messages []map[string]string, model string) string {
|
||
|
|
m.calls++
|
||
|
|
return m.response
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *mockStreamer) StreamWithTemp(messages []map[string]string, model string, temp float64) string {
|
||
|
|
m.calls++
|
||
|
|
return m.response
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *mockStreamer) StreamSilent(messages []map[string]string, model string) string {
|
||
|
|
m.calls++
|
||
|
|
return m.response
|
||
|
|
}
|
||
|
|
|
||
|
|
// Ensure mockStreamer satisfies the interface at compile time.
|
||
|
|
var _ grok.AIClient = (*mockStreamer)(nil)
|
||
|
|
|
||
|
|
// withMockClient injects mock and returns a restore func.
|
||
|
|
func withMockClient(mock grok.AIClient) func() {
|
||
|
|
orig := newGrokClient
|
||
|
|
newGrokClient = func() grok.AIClient { return mock }
|
||
|
|
return func() { newGrokClient = orig }
|
||
|
|
}
|
||
|
|
|
||
|
|
// withMockGit injects a fake git runner and returns a restore func.
|
||
|
|
func withMockGit(fn func([]string) (string, error)) func() {
|
||
|
|
orig := gitRun
|
||
|
|
gitRun = fn
|
||
|
|
return func() { gitRun = orig }
|
||
|
|
}
|
||
|
|
|
||
|
|
// testCmd returns a minimal cobra command with the model flag registered.
|
||
|
|
func testCmd() *cobra.Command {
|
||
|
|
c := &cobra.Command{}
|
||
|
|
c.Flags().String("model", "", "")
|
||
|
|
return c
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- runHistory ---
|
||
|
|
|
||
|
|
func TestRunHistory(t *testing.T) {
|
||
|
|
t.Run("calls AI with log output", func(t *testing.T) {
|
||
|
|
mock := &mockStreamer{response: "3 commits: feat, fix, chore"}
|
||
|
|
defer withMockClient(mock)()
|
||
|
|
defer withMockGit(func(args []string) (string, error) {
|
||
|
|
return "abc1234 feat: add thing\ndef5678 fix: bug", nil
|
||
|
|
})()
|
||
|
|
|
||
|
|
runHistory(testCmd(), nil)
|
||
|
|
|
||
|
|
if mock.calls != 1 {
|
||
|
|
t.Errorf("expected 1 AI call, got %d", mock.calls)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("no commits — skips AI", func(t *testing.T) {
|
||
|
|
mock := &mockStreamer{}
|
||
|
|
defer withMockClient(mock)()
|
||
|
|
defer withMockGit(func(args []string) (string, error) {
|
||
|
|
return "", nil
|
||
|
|
})()
|
||
|
|
|
||
|
|
runHistory(testCmd(), nil)
|
||
|
|
|
||
|
|
if mock.calls != 0 {
|
||
|
|
t.Errorf("expected 0 AI calls, got %d", mock.calls)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("git error — skips AI", func(t *testing.T) {
|
||
|
|
mock := &mockStreamer{}
|
||
|
|
defer withMockClient(mock)()
|
||
|
|
defer withMockGit(func(args []string) (string, error) {
|
||
|
|
return "", errors.New("not a git repo")
|
||
|
|
})()
|
||
|
|
|
||
|
|
runHistory(testCmd(), nil)
|
||
|
|
|
||
|
|
if mock.calls != 0 {
|
||
|
|
t.Errorf("expected 0 AI calls, got %d", mock.calls)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- runReview ---
|
||
|
|
|
||
|
|
func TestRunReview(t *testing.T) {
|
||
|
|
t.Run("reviews with diff and status", func(t *testing.T) {
|
||
|
|
mock := &mockStreamer{response: "looks good"}
|
||
|
|
defer withMockClient(mock)()
|
||
|
|
callCount := 0
|
||
|
|
defer withMockGit(func(args []string) (string, error) {
|
||
|
|
callCount++
|
||
|
|
switch args[0] {
|
||
|
|
case "diff":
|
||
|
|
return "diff content", nil
|
||
|
|
case "status":
|
||
|
|
return "M main.go", nil
|
||
|
|
}
|
||
|
|
return "", nil
|
||
|
|
})()
|
||
|
|
|
||
|
|
runReview(testCmd(), nil)
|
||
|
|
|
||
|
|
if mock.calls != 1 {
|
||
|
|
t.Errorf("expected 1 AI call, got %d", mock.calls)
|
||
|
|
}
|
||
|
|
if callCount != 2 {
|
||
|
|
t.Errorf("expected 2 git calls (diff + status), got %d", callCount)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("git diff error — skips AI", func(t *testing.T) {
|
||
|
|
mock := &mockStreamer{}
|
||
|
|
defer withMockClient(mock)()
|
||
|
|
defer withMockGit(func(args []string) (string, error) {
|
||
|
|
if args[0] == "diff" {
|
||
|
|
return "", errors.New("git error")
|
||
|
|
}
|
||
|
|
return "", nil
|
||
|
|
})()
|
||
|
|
|
||
|
|
runReview(testCmd(), nil)
|
||
|
|
|
||
|
|
if mock.calls != 0 {
|
||
|
|
t.Errorf("expected 0 AI calls, got %d", mock.calls)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("git status error — skips AI", func(t *testing.T) {
|
||
|
|
mock := &mockStreamer{}
|
||
|
|
defer withMockClient(mock)()
|
||
|
|
defer withMockGit(func(args []string) (string, error) {
|
||
|
|
if args[0] == "status" {
|
||
|
|
return "", errors.New("git error")
|
||
|
|
}
|
||
|
|
return "diff content", nil
|
||
|
|
})()
|
||
|
|
|
||
|
|
runReview(testCmd(), nil)
|
||
|
|
|
||
|
|
if mock.calls != 0 {
|
||
|
|
t.Errorf("expected 0 AI calls, got %d", mock.calls)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- runCommit ---
|
||
|
|
|
||
|
|
func TestRunCommit(t *testing.T) {
|
||
|
|
t.Run("no staged changes — skips AI", func(t *testing.T) {
|
||
|
|
mock := &mockStreamer{}
|
||
|
|
defer withMockClient(mock)()
|
||
|
|
defer withMockGit(func(args []string) (string, error) {
|
||
|
|
return "", nil
|
||
|
|
})()
|
||
|
|
|
||
|
|
runCommit(testCmd(), nil)
|
||
|
|
|
||
|
|
if mock.calls != 0 {
|
||
|
|
t.Errorf("expected 0 AI calls, got %d", mock.calls)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("git error — skips AI", func(t *testing.T) {
|
||
|
|
mock := &mockStreamer{}
|
||
|
|
defer withMockClient(mock)()
|
||
|
|
defer withMockGit(func(args []string) (string, error) {
|
||
|
|
return "", errors.New("not a git repo")
|
||
|
|
})()
|
||
|
|
|
||
|
|
runCommit(testCmd(), nil)
|
||
|
|
|
||
|
|
if mock.calls != 0 {
|
||
|
|
t.Errorf("expected 0 AI calls, got %d", mock.calls)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("with staged changes — calls AI then cancels via stdin", func(t *testing.T) {
|
||
|
|
mock := &mockStreamer{response: "feat(cmd): add thing"}
|
||
|
|
defer withMockClient(mock)()
|
||
|
|
defer withMockGit(func(args []string) (string, error) {
|
||
|
|
return "diff --git a/foo.go b/foo.go\n+func bar() {}", nil
|
||
|
|
})()
|
||
|
|
|
||
|
|
// Pipe "n\n" so the confirmation prompt returns without committing.
|
||
|
|
r, w, _ := os.Pipe()
|
||
|
|
origStdin := os.Stdin
|
||
|
|
os.Stdin = r
|
||
|
|
w.WriteString("n\n")
|
||
|
|
w.Close()
|
||
|
|
defer func() { os.Stdin = origStdin }()
|
||
|
|
|
||
|
|
runCommit(testCmd(), nil)
|
||
|
|
|
||
|
|
if mock.calls != 1 {
|
||
|
|
t.Errorf("expected 1 AI call, got %d", mock.calls)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- runCommitMsg ---
|
||
|
|
|
||
|
|
func TestRunCommitMsg(t *testing.T) {
|
||
|
|
t.Run("no staged changes — skips AI", func(t *testing.T) {
|
||
|
|
mock := &mockStreamer{}
|
||
|
|
defer withMockClient(mock)()
|
||
|
|
defer withMockGit(func(args []string) (string, error) {
|
||
|
|
return "", nil
|
||
|
|
})()
|
||
|
|
|
||
|
|
runCommitMsg(testCmd(), nil)
|
||
|
|
|
||
|
|
if mock.calls != 0 {
|
||
|
|
t.Errorf("expected 0 AI calls, got %d", mock.calls)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("with staged changes — calls AI and prints message", func(t *testing.T) {
|
||
|
|
mock := &mockStreamer{response: "feat(api): add endpoint"}
|
||
|
|
defer withMockClient(mock)()
|
||
|
|
defer withMockGit(func(args []string) (string, error) {
|
||
|
|
return "diff --git a/api.go b/api.go", nil
|
||
|
|
})()
|
||
|
|
|
||
|
|
runCommitMsg(testCmd(), nil)
|
||
|
|
|
||
|
|
if mock.calls != 1 {
|
||
|
|
t.Errorf("expected 1 AI call, got %d", mock.calls)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- runPRDescribe ---
|
||
|
|
|
||
|
|
func TestRunPRDescribe(t *testing.T) {
|
||
|
|
t.Run("no changes on branch — skips AI", func(t *testing.T) {
|
||
|
|
mock := &mockStreamer{}
|
||
|
|
defer withMockClient(mock)()
|
||
|
|
defer withMockGit(func(args []string) (string, error) {
|
||
|
|
return "", nil // both diff calls return empty
|
||
|
|
})()
|
||
|
|
|
||
|
|
runPRDescribe(testCmd(), nil)
|
||
|
|
|
||
|
|
if mock.calls != 0 {
|
||
|
|
t.Errorf("expected 0 AI calls, got %d", mock.calls)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("first diff succeeds — calls AI", func(t *testing.T) {
|
||
|
|
mock := &mockStreamer{response: "## PR Title\n\nDescription"}
|
||
|
|
defer withMockClient(mock)()
|
||
|
|
callCount := 0
|
||
|
|
defer withMockGit(func(args []string) (string, error) {
|
||
|
|
callCount++
|
||
|
|
if callCount == 1 {
|
||
|
|
return "diff --git a/foo.go b/foo.go", nil
|
||
|
|
}
|
||
|
|
return "", nil
|
||
|
|
})()
|
||
|
|
|
||
|
|
runPRDescribe(testCmd(), nil)
|
||
|
|
|
||
|
|
if mock.calls != 1 {
|
||
|
|
t.Errorf("expected 1 AI call, got %d", mock.calls)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("first diff empty, second succeeds — calls AI", func(t *testing.T) {
|
||
|
|
mock := &mockStreamer{response: "PR description"}
|
||
|
|
defer withMockClient(mock)()
|
||
|
|
callCount := 0
|
||
|
|
defer withMockGit(func(args []string) (string, error) {
|
||
|
|
callCount++
|
||
|
|
if callCount == 2 {
|
||
|
|
return "diff --git a/bar.go b/bar.go", nil
|
||
|
|
}
|
||
|
|
return "", nil
|
||
|
|
})()
|
||
|
|
|
||
|
|
runPRDescribe(testCmd(), nil)
|
||
|
|
|
||
|
|
if mock.calls != 1 {
|
||
|
|
t.Errorf("expected 1 AI call, got %d", mock.calls)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("second diff error — skips AI", func(t *testing.T) {
|
||
|
|
mock := &mockStreamer{}
|
||
|
|
defer withMockClient(mock)()
|
||
|
|
callCount := 0
|
||
|
|
defer withMockGit(func(args []string) (string, error) {
|
||
|
|
callCount++
|
||
|
|
if callCount == 2 {
|
||
|
|
return "", errors.New("no remote")
|
||
|
|
}
|
||
|
|
return "", nil
|
||
|
|
})()
|
||
|
|
|
||
|
|
runPRDescribe(testCmd(), nil)
|
||
|
|
|
||
|
|
if mock.calls != 0 {
|
||
|
|
t.Errorf("expected 0 AI calls, got %d", mock.calls)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- runLint / processDocsFile (error paths only — linter/file I/O) ---
|
||
|
|
|
||
|
|
func TestRunLintFileNotFound(t *testing.T) {
|
||
|
|
// Reset flags to defaults
|
||
|
|
dryRun = false
|
||
|
|
autoFix = false
|
||
|
|
|
||
|
|
// Pass a non-existent file; should return without calling AI.
|
||
|
|
mock := &mockStreamer{}
|
||
|
|
defer withMockClient(mock)()
|
||
|
|
|
||
|
|
runLint(testCmd(), []string{"/nonexistent/path/file.go"})
|
||
|
|
|
||
|
|
if mock.calls != 0 {
|
||
|
|
t.Errorf("expected 0 AI calls for missing file, got %d", mock.calls)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestProcessDocsFileNotFound(t *testing.T) {
|
||
|
|
mock := &mockStreamer{}
|
||
|
|
processDocsFile(mock, "grok-4", "/nonexistent/path/file.go")
|
||
|
|
|
||
|
|
if mock.calls != 0 {
|
||
|
|
t.Errorf("expected 0 AI calls for missing file, got %d", mock.calls)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestProcessDocsFileUnsupportedLanguage(t *testing.T) {
|
||
|
|
// Create a temp file with an unsupported extension.
|
||
|
|
f, err := os.CreateTemp("", "test*.xyz")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
f.Close()
|
||
|
|
defer os.Remove(f.Name())
|
||
|
|
|
||
|
|
mock := &mockStreamer{}
|
||
|
|
processDocsFile(mock, "grok-4", f.Name())
|
||
|
|
|
||
|
|
if mock.calls != 0 {
|
||
|
|
t.Errorf("expected 0 AI calls for unsupported language, got %d", mock.calls)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestProcessDocsFilePreviewAndCancel(t *testing.T) {
|
||
|
|
f, err := os.CreateTemp("", "test*.go")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, err := f.WriteString("package main\n\nfunc Foo() {}\n"); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
f.Close()
|
||
|
|
defer os.Remove(f.Name())
|
||
|
|
defer os.Remove(f.Name() + ".bak")
|
||
|
|
|
||
|
|
mock := &mockStreamer{response: "package main\n\n// Foo does nothing.\nfunc Foo() {}\n"}
|
||
|
|
|
||
|
|
origAutoApply := autoApply
|
||
|
|
autoApply = false
|
||
|
|
defer func() { autoApply = origAutoApply }()
|
||
|
|
|
||
|
|
// Pipe "n\n" so confirmation returns without writing.
|
||
|
|
r, w, _ := os.Pipe()
|
||
|
|
origStdin := os.Stdin
|
||
|
|
os.Stdin = r
|
||
|
|
w.WriteString("n\n")
|
||
|
|
w.Close()
|
||
|
|
defer func() { os.Stdin = origStdin }()
|
||
|
|
|
||
|
|
processDocsFile(mock, "grok-4", f.Name())
|
||
|
|
|
||
|
|
if mock.calls != 1 {
|
||
|
|
t.Errorf("expected 1 AI call, got %d", mock.calls)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestProcessDocsFileAutoApply(t *testing.T) {
|
||
|
|
f, err := os.CreateTemp("", "test*.go")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
original := "package main\n\nfunc Bar() {}\n"
|
||
|
|
if _, err := f.WriteString(original); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
f.Close()
|
||
|
|
defer os.Remove(f.Name())
|
||
|
|
defer os.Remove(f.Name() + ".bak")
|
||
|
|
|
||
|
|
// CleanCodeResponse will trim the trailing newline from the AI response.
|
||
|
|
aiResponse := "package main\n\n// Bar does nothing.\nfunc Bar() {}\n"
|
||
|
|
documented := "package main\n\n// Bar does nothing.\nfunc Bar() {}"
|
||
|
|
mock := &mockStreamer{response: aiResponse}
|
||
|
|
|
||
|
|
origAutoApply := autoApply
|
||
|
|
autoApply = true
|
||
|
|
defer func() { autoApply = origAutoApply }()
|
||
|
|
|
||
|
|
processDocsFile(mock, "grok-4", f.Name())
|
||
|
|
|
||
|
|
if mock.calls != 1 {
|
||
|
|
t.Errorf("expected 1 AI call, got %d", mock.calls)
|
||
|
|
}
|
||
|
|
// Verify file was rewritten with documented content.
|
||
|
|
content, _ := os.ReadFile(f.Name())
|
||
|
|
if string(content) != documented {
|
||
|
|
t.Errorf("file content = %q, want %q", string(content), documented)
|
||
|
|
}
|
||
|
|
// Verify backup was created with original content.
|
||
|
|
backup, _ := os.ReadFile(f.Name() + ".bak")
|
||
|
|
if string(backup) != original {
|
||
|
|
t.Errorf("backup content = %q, want %q", string(backup), original)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestRunDocs(t *testing.T) {
|
||
|
|
// runDocs with a missing file: should call processDocsFile which returns early.
|
||
|
|
mock := &mockStreamer{}
|
||
|
|
defer withMockClient(mock)()
|
||
|
|
|
||
|
|
runDocs(testCmd(), []string{"/nonexistent/file.go"})
|
||
|
|
|
||
|
|
if mock.calls != 0 {
|
||
|
|
t.Errorf("expected 0 AI calls for missing file, got %d", mock.calls)
|
||
|
|
}
|
||
|
|
}
|