grokkit/cmd/run_test.go
Greg Gauthier b1d3a445ec
Some checks failed
Auto-complete TODO / move-todo (pull_request) Failing after 1s
CI / Test (pull_request) Successful in 34s
CI / Lint (pull_request) Successful in 24s
CI / Build (pull_request) Successful in 20s
Release / Create Release (push) Successful in 35s
feat(prdescribe): add configurable base branch flag
Introduce a new --base flag (default: "master") to specify the base branch for diff comparison in the PR describe command. Update logic to use this base in git diff commands and fallback to origin/base. Adjust no-changes message accordingly. Add tests for custom base and default behavior.
2026-03-04 18:11:28 +00:00

502 lines
12 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 common flags registered.
func testCmd() *cobra.Command {
c := &cobra.Command{}
c.Flags().String("model", "", "")
c.Flags().String("base", "master", "")
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
if _, err := w.WriteString("n\n"); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
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("uses custom base branch", func(t *testing.T) {
mock := &mockStreamer{response: "PR description"}
defer withMockClient(mock)()
var capturedArgs []string
defer withMockGit(func(args []string) (string, error) {
capturedArgs = args
return "diff content", nil
})()
cmd := testCmd()
if err := cmd.Flags().Set("base", "develop"); err != nil {
t.Fatal(err)
}
runPRDescribe(cmd, nil)
if mock.calls != 1 {
t.Errorf("expected 1 AI call, got %d", mock.calls)
}
// Expect "diff", "develop..HEAD", "--no-color"
expectedArg := "develop..HEAD"
found := false
for _, arg := range capturedArgs {
if arg == expectedArg {
found = true
break
}
}
if !found {
t.Errorf("expected arg %q not found in %v", expectedArg, capturedArgs)
}
})
t.Run("defaults to master", func(t *testing.T) {
mock := &mockStreamer{response: "PR description"}
defer withMockClient(mock)()
var capturedArgs []string
defer withMockGit(func(args []string) (string, error) {
capturedArgs = args
return "diff content", nil
})()
runPRDescribe(testCmd(), nil)
if mock.calls != 1 {
t.Errorf("expected 1 AI call, got %d", mock.calls)
}
expectedArg := "master..HEAD"
found := false
for _, arg := range capturedArgs {
if arg == expectedArg {
found = true
break
}
}
if !found {
t.Errorf("expected arg %q not found in %v", expectedArg, capturedArgs)
}
})
}
// --- 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)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
defer func() { _ = 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)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Remove(f.Name()) }()
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
if _, err := w.WriteString("n\n"); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
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)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Remove(f.Name()) }()
// 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)
}
}
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)
}
}