diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-16 03:10:55 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-16 03:10:55 +0200 |
| commit | 1fc1611fa99993cab5dc8bf0844183285296e3b2 (patch) | |
| tree | c5c9b8b5abac5b5d4c0d56ed90b0580184cc4383 /internal | |
| parent | 12090f25a3677291863dbb80277bdad3eaec0324 (diff) | |
Release v0.24.0v0.24.0
Bring unit test coverage from ~75% to 85.1% project-wide. All internal
packages now exceed 80% coverage. Refactored cmd entrypoints to extract
testable run() functions with injectable seams.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/editor/editor_test.go | 140 | ||||
| -rw-r--r-- | internal/gotest/heuristics_test.go | 120 | ||||
| -rw-r--r-- | internal/hexaicli/run_output_test.go | 538 | ||||
| -rw-r--r-- | internal/hexaimcp/run_test.go | 314 | ||||
| -rw-r--r-- | internal/runtimeconfig/store_test.go | 279 | ||||
| -rw-r--r-- | internal/stats/stats_test.go | 250 | ||||
| -rw-r--r-- | internal/testutil/fixtures_test.go | 41 | ||||
| -rw-r--r-- | internal/tmux/status_coverage_test.go | 418 | ||||
| -rw-r--r-- | internal/tmuxedit/agent_test.go | 46 | ||||
| -rw-r--r-- | internal/tmuxedit/agentutil_test.go | 60 | ||||
| -rw-r--r-- | internal/tmuxedit/claude_agent_test.go | 63 | ||||
| -rw-r--r-- | internal/tmuxedit/cursor_agent_test.go | 49 | ||||
| -rw-r--r-- | internal/tmuxedit/history_test.go | 128 | ||||
| -rw-r--r-- | internal/tmuxedit/run_test.go | 101 | ||||
| -rw-r--r-- | internal/version.go | 2 |
15 files changed, 2547 insertions, 2 deletions
diff --git a/internal/editor/editor_test.go b/internal/editor/editor_test.go index 06cc165..260fb85 100644 --- a/internal/editor/editor_test.go +++ b/internal/editor/editor_test.go @@ -1,11 +1,32 @@ package editor import ( + "errors" "os" "path/filepath" "testing" ) +// TestRunEditor_Default exercises the default RunEditor function with a harmless command. +func TestRunEditor_Default(t *testing.T) { + t.Setenv("HEXAI_EDITOR", "true") // /usr/bin/true — exits 0 immediately + tmp := filepath.Join(t.TempDir(), "test.md") + if err := os.WriteFile(tmp, []byte("hello"), 0o600); err != nil { + t.Fatal(err) + } + if err := RunEditor("true", tmp); err != nil { + t.Fatalf("RunEditor with 'true': %v", err) + } +} + +// TestRunEditor_Default_BadCommand verifies RunEditor returns an error for a nonexistent command. +func TestRunEditor_Default_BadCommand(t *testing.T) { + err := RunEditor("nonexistent-editor-cmd-12345", "/dev/null") + if err == nil { + t.Fatal("expected error for nonexistent editor command") + } +} + func TestResolve_EnvPriority(t *testing.T) { t.Setenv("HEXAI_EDITOR", "ed1") t.Setenv("EDITOR", "ed2") @@ -20,6 +41,26 @@ func TestResolve_EnvPriority(t *testing.T) { } } +// TestResolve_NoEditor verifies the error when neither HEXAI_EDITOR nor EDITOR is set. +func TestResolve_NoEditor(t *testing.T) { + t.Setenv("HEXAI_EDITOR", "") + t.Setenv("EDITOR", "") + _, err := Resolve() + if err == nil { + t.Fatal("expected error when no editor is configured") + } +} + +// TestResolve_WhitespaceOnly verifies that whitespace-only values are treated as empty. +func TestResolve_WhitespaceOnly(t *testing.T) { + t.Setenv("HEXAI_EDITOR", " ") + t.Setenv("EDITOR", " \t ") + _, err := Resolve() + if err == nil { + t.Fatal("expected error for whitespace-only editor values") + } +} + func TestOpenTempAndEdit_UsesRunEditor(t *testing.T) { old := RunEditor t.Cleanup(func() { RunEditor = old }) @@ -42,3 +83,102 @@ func TestOpenTempAndEdit_UsesRunEditor(t *testing.T) { t.Fatalf("expected .md suffix: %s", capturedPath) } } + +// TestOpenTempAndEdit_NoEditor verifies error propagation when no editor is configured. +func TestOpenTempAndEdit_NoEditor(t *testing.T) { + t.Setenv("HEXAI_EDITOR", "") + t.Setenv("EDITOR", "") + _, err := OpenTempAndEdit(nil) + if err == nil { + t.Fatal("expected error when no editor is set") + } +} + +// TestOpenTempAndEdit_NilInitial verifies that nil initial content works (empty file). +func TestOpenTempAndEdit_NilInitial(t *testing.T) { + old := RunEditor + t.Cleanup(func() { RunEditor = old }) + t.Setenv("HEXAI_EDITOR", "dummy") + RunEditor = func(editor, path string) error { + // simulate user writing content into a file that started empty + return os.WriteFile(path, []byte("result"), 0o600) + } + out, err := OpenTempAndEdit(nil) + if err != nil { + t.Fatalf("OpenTempAndEdit with nil initial: %v", err) + } + if out != "result" { + t.Fatalf("unexpected content: %q", out) + } +} + +// TestOpenTempAndEdit_EmptyInitial verifies that empty (zero-length) initial content +// skips the write branch but still works end-to-end. +func TestOpenTempAndEdit_EmptyInitial(t *testing.T) { + old := RunEditor + t.Cleanup(func() { RunEditor = old }) + t.Setenv("HEXAI_EDITOR", "dummy") + RunEditor = func(editor, path string) error { + return os.WriteFile(path, []byte(" trimmed "), 0o600) + } + out, err := OpenTempAndEdit([]byte{}) + if err != nil { + t.Fatalf("OpenTempAndEdit with empty initial: %v", err) + } + if out != "trimmed" { + t.Fatalf("expected trimmed content, got %q", out) + } +} + +// TestOpenTempAndEdit_EditorError verifies that an editor failure propagates the error. +func TestOpenTempAndEdit_EditorError(t *testing.T) { + old := RunEditor + t.Cleanup(func() { RunEditor = old }) + t.Setenv("HEXAI_EDITOR", "dummy") + editorErr := errors.New("editor crashed") + RunEditor = func(editor, path string) error { + return editorErr + } + _, err := OpenTempAndEdit([]byte("some content")) + if err == nil { + t.Fatal("expected error when editor fails") + } + if !errors.Is(err, editorErr) { + t.Fatalf("expected editor error, got: %v", err) + } +} + +// TestOpenTempAndEdit_EditorDeletesFile verifies error when the editor removes the temp file. +func TestOpenTempAndEdit_EditorDeletesFile(t *testing.T) { + old := RunEditor + t.Cleanup(func() { RunEditor = old }) + t.Setenv("HEXAI_EDITOR", "dummy") + RunEditor = func(editor, path string) error { + // simulate the editor deleting the file + return os.Remove(path) + } + _, err := OpenTempAndEdit([]byte("content")) + if err == nil { + t.Fatal("expected error when temp file is deleted by editor") + } +} + +// TestOpenTempAndEdit_TempFileCleanup verifies the temp file is removed after success. +func TestOpenTempAndEdit_TempFileCleanup(t *testing.T) { + old := RunEditor + t.Cleanup(func() { RunEditor = old }) + t.Setenv("HEXAI_EDITOR", "dummy") + var capturedPath string + RunEditor = func(editor, path string) error { + capturedPath = path + return os.WriteFile(path, []byte("done"), 0o600) + } + _, err := OpenTempAndEdit(nil) + if err != nil { + t.Fatalf("OpenTempAndEdit: %v", err) + } + // The deferred os.Remove should have cleaned up the temp file + if _, err := os.Stat(capturedPath); !os.IsNotExist(err) { + t.Fatalf("temp file was not cleaned up: %s", capturedPath) + } +} diff --git a/internal/gotest/heuristics_test.go b/internal/gotest/heuristics_test.go index 831262d..6597238 100644 --- a/internal/gotest/heuristics_test.go +++ b/internal/gotest/heuristics_test.go @@ -12,6 +12,22 @@ func TestParsePackageName(t *testing.T) { } } +func TestParsePackageName_TabAfterName(t *testing.T) { + // Covers the tab-trimming branch in ParsePackageName. + lines := []string{"package mypkg\t// tab then comment"} + if got := ParsePackageName(lines); got != "mypkg" { + t.Fatalf("got %q, want %q", got, "mypkg") + } +} + +func TestParsePackageName_SpaceAfterName(t *testing.T) { + // Covers the space-trimming branch (no comment, just trailing space). + lines := []string{"package mypkg "} + if got := ParsePackageName(lines); got != "mypkg" { + t.Fatalf("got %q, want %q", got, "mypkg") + } +} + func TestFindFunctionAtLine_NoBody(t *testing.T) { lines := []string{"func X(a int)", "// comment"} start, end := FindFunctionAtLine(lines, 0) @@ -20,6 +36,81 @@ func TestFindFunctionAtLine_NoBody(t *testing.T) { } } +func TestFindFunctionAtLine_EmptyLines(t *testing.T) { + // Covers the empty-lines early return. + start, end := FindFunctionAtLine([]string{}, 0) + if start != -1 || end != -1 { + t.Fatalf("expected -1,-1 for empty input, got %d,%d", start, end) + } +} + +func TestFindFunctionAtLine_NegativeIdx(t *testing.T) { + // Covers the idx < 0 clamping branch. + lines := []string{"func Foo() {", " return", "}"} + start, end := FindFunctionAtLine(lines, -5) + if start != 0 || end != 2 { + t.Fatalf("expected 0,2 got %d,%d", start, end) + } +} + +func TestFindFunctionAtLine_IdxBeyondEnd(t *testing.T) { + // Covers the idx >= len(lines) clamping branch. + // The last line contains "func " so the backward scan finds it directly. + lines := []string{"package main", "", "func Last() { }"} + start, end := FindFunctionAtLine(lines, 100) + if start != 2 || end != 2 { + t.Fatalf("expected 2,2 got %d,%d", start, end) + } +} + +func TestFindFunctionAtLine_ClosingBraceBeforeFunc(t *testing.T) { + // When scanning backward, hitting '}' before 'func ' means no enclosing function. + lines := []string{"func A() {", "}", " x := 1"} + start, end := FindFunctionAtLine(lines, 2) + if start != -1 || end != -1 { + t.Fatalf("expected -1,-1 got %d,%d", start, end) + } +} + +func TestFindFunctionAtLine_NormalFunction(t *testing.T) { + // Covers the normal path: finding a complete function with braces. + lines := []string{ + "package main", + "", + "func Hello() {", + " fmt.Println(\"hi\")", + "}", + } + start, end := FindFunctionAtLine(lines, 3) + if start != 2 || end != 4 { + t.Fatalf("expected 2,4 got %d,%d", start, end) + } +} + +func TestFindFunctionAtLine_UnclosedBrace(t *testing.T) { + // Covers the branch where opening brace is seen but never closed. + lines := []string{"func Broken() {", " x := 1"} + start, end := FindFunctionAtLine(lines, 0) + if start != 0 || end != -1 { + t.Fatalf("expected 0,-1 for unclosed brace, got %d,%d", start, end) + } +} + +func TestFindFunctionAtLine_NestedBraces(t *testing.T) { + // Covers depth tracking with nested braces. + lines := []string{ + "func Nested() {", + " if true {", + " x := 1", + " }", + "}", + } + start, end := FindFunctionAtLine(lines, 2) + if start != 0 || end != 4 { + t.Fatalf("expected 0,4 got %d,%d", start, end) + } +} + func TestDeriveFuncName(t *testing.T) { if got := DeriveFuncName("func Sum(a int) int { return a }"); got != "Sum" { t.Fatalf("got %q", got) @@ -29,6 +120,28 @@ func TestDeriveFuncName(t *testing.T) { } } +func TestDeriveFuncName_NotAFunc(t *testing.T) { + // Covers the early return when line doesn't start with "func ". + if got := DeriveFuncName("var x = 1"); got != "" { + t.Fatalf("expected empty, got %q", got) + } +} + +func TestDeriveFuncName_MultiLine(t *testing.T) { + // Covers the firstLine newline-splitting branch. + code := "func Multi() {\n return\n}" + if got := DeriveFuncName(code); got != "Multi" { + t.Fatalf("got %q, want %q", got, "Multi") + } +} + +func TestDeriveFuncName_MethodReceiverNoParenAfter(t *testing.T) { + // Covers the case where receiver is parsed but no '(' follows the name. + if got := DeriveFuncName("func (t *T) "); got != "" { + t.Fatalf("expected empty, got %q", got) + } +} + func TestExportName(t *testing.T) { if got := ExportName("sum"); got != "Sum" { t.Fatalf("got %q", got) @@ -37,3 +150,10 @@ func TestExportName(t *testing.T) { t.Fatalf("got %q", got) } } + +func TestExportName_Empty(t *testing.T) { + // Covers the empty-string early return. + if got := ExportName(""); got != "" { + t.Fatalf("expected empty, got %q", got) + } +} diff --git a/internal/hexaicli/run_output_test.go b/internal/hexaicli/run_output_test.go new file mode 100644 index 0000000..f4e47fe --- /dev/null +++ b/internal/hexaicli/run_output_test.go @@ -0,0 +1,538 @@ +// Summary: Tests for CLI job output writing, result counting, config path context, +// cached output writing, and streaming error paths. +package hexaicli + +import ( + "bytes" + "context" + "errors" + "fmt" + "strings" + "testing" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/llm" +) + +func TestCliJobResultCount(t *testing.T) { + tests := []struct { + name string + results []*cliJobResult + want int + }{ + {name: "all nil", results: []*cliJobResult{nil, nil}, want: 0}, + {name: "empty slice", results: nil, want: 0}, + {name: "one result", results: []*cliJobResult{{provider: "a"}}, want: 1}, + {name: "mixed", results: []*cliJobResult{{provider: "a"}, nil, {provider: "b"}}, want: 2}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := cliJobResultCount(tc.results); got != tc.want { + t.Fatalf("cliJobResultCount = %d, want %d", got, tc.want) + } + }) + } +} + +func TestWriteCLIJobOutput_WithHeading(t *testing.T) { + var buf bytes.Buffer + res := &cliJobResult{provider: "openai", model: "gpt-4.1", output: "hello world"} + if err := writeCLIJobOutput(&buf, res, true); err != nil { + t.Fatalf("writeCLIJobOutput: %v", err) + } + got := buf.String() + if !strings.Contains(got, "=== openai:gpt-4.1 ===") { + t.Fatalf("expected heading, got %q", got) + } + if !strings.Contains(got, "hello world") { + t.Fatalf("expected output, got %q", got) + } + // Output without trailing newline should get one appended. + if !strings.HasSuffix(got, "\n") { + t.Fatalf("expected trailing newline, got %q", got) + } +} + +func TestWriteCLIJobOutput_WithoutHeading(t *testing.T) { + var buf bytes.Buffer + res := &cliJobResult{provider: "openai", model: "gpt-4.1", output: "hello\n"} + if err := writeCLIJobOutput(&buf, res, false); err != nil { + t.Fatalf("writeCLIJobOutput: %v", err) + } + got := buf.String() + if strings.Contains(got, "===") { + t.Fatalf("expected no heading, got %q", got) + } + // Output already ends with newline; no extra newline should be appended. + if got != "hello\n" { + t.Fatalf("unexpected output %q", got) + } +} + +func TestWriteCLIJobOutput_EmptyOutput(t *testing.T) { + var buf bytes.Buffer + res := &cliJobResult{provider: "p", model: "m", output: ""} + if err := writeCLIJobOutput(&buf, res, true); err != nil { + t.Fatalf("writeCLIJobOutput: %v", err) + } + // Should print heading but no body content. + if !strings.Contains(buf.String(), "=== p:m ===") { + t.Fatalf("expected heading even for empty output, got %q", buf.String()) + } +} + +func TestWriteCLIJobOutputs_SingleResult(t *testing.T) { + var buf bytes.Buffer + results := []*cliJobResult{{provider: "p", model: "m", output: "out"}} + if err := writeCLIJobOutputs(&buf, results); err != nil { + t.Fatalf("writeCLIJobOutputs: %v", err) + } + // Single result: showHeading is false (count == 1). + if strings.Contains(buf.String(), "===") { + t.Fatalf("single result should have no heading, got %q", buf.String()) + } + if !strings.Contains(buf.String(), "out") { + t.Fatalf("expected output, got %q", buf.String()) + } +} + +func TestWriteCLIJobOutputs_MultipleResults(t *testing.T) { + var buf bytes.Buffer + results := []*cliJobResult{ + {provider: "a", model: "m1", output: "first"}, + {provider: "b", model: "m2", output: "second"}, + } + if err := writeCLIJobOutputs(&buf, results); err != nil { + t.Fatalf("writeCLIJobOutputs: %v", err) + } + got := buf.String() + // Multiple results: headings shown. + if !strings.Contains(got, "=== a:m1 ===") || !strings.Contains(got, "=== b:m2 ===") { + t.Fatalf("expected headings for both results, got %q", got) + } + // Separator newline between results. + if !strings.Contains(got, "first") || !strings.Contains(got, "second") { + t.Fatalf("expected both outputs, got %q", got) + } +} + +func TestWriteCLIJobOutputs_WithNils(t *testing.T) { + var buf bytes.Buffer + results := []*cliJobResult{nil, {provider: "a", model: "m", output: "ok"}, nil} + if err := writeCLIJobOutputs(&buf, results); err != nil { + t.Fatalf("writeCLIJobOutputs: %v", err) + } + // Only one non-nil result, so count=1, no heading. + if strings.Contains(buf.String(), "===") { + t.Fatalf("single non-nil result should have no heading, got %q", buf.String()) + } +} + +func TestWriteCLIJobOutputs_Empty(t *testing.T) { + var buf bytes.Buffer + if err := writeCLIJobOutputs(&buf, nil); err != nil { + t.Fatalf("writeCLIJobOutputs: %v", err) + } + if buf.Len() != 0 { + t.Fatalf("expected empty output, got %q", buf.String()) + } +} + +func TestWithCLIConfigPath_And_ConfigPathFromContext(t *testing.T) { + // Normal usage. + ctx := WithCLIConfigPath(context.Background(), "/tmp/config.toml") + if got := configPathFromContext(ctx); got != "/tmp/config.toml" { + t.Fatalf("configPathFromContext = %q, want /tmp/config.toml", got) + } + + // With whitespace trimming. + ctx = WithCLIConfigPath(context.Background(), " /tmp/cfg.toml ") + if got := configPathFromContext(ctx); got != "/tmp/cfg.toml" { + t.Fatalf("configPathFromContext = %q, want /tmp/cfg.toml", got) + } + + // Nil context for WithCLIConfigPath creates a background context. + ctx = WithCLIConfigPath(nil, "/path") + if got := configPathFromContext(ctx); got != "/path" { + t.Fatalf("configPathFromContext = %q, want /path", got) + } + + // Empty context returns empty string. + if got := configPathFromContext(context.Background()); got != "" { + t.Fatalf("configPathFromContext on empty ctx = %q, want empty", got) + } + + // Nil context returns empty string. + if got := configPathFromContext(nil); got != "" { + t.Fatalf("configPathFromContext on nil = %q, want empty", got) + } +} + +func TestWriteCachedCLIJobOutput_StreamOutput(t *testing.T) { + var buf bytes.Buffer + // streamOutput=true, printer=nil => writes to stdout. + if err := writeCachedCLIJobOutput("cached", &buf, nil, 0, true); err != nil { + t.Fatalf("writeCachedCLIJobOutput: %v", err) + } + if buf.String() != "cached" { + t.Fatalf("expected 'cached', got %q", buf.String()) + } +} + +func TestWriteCachedCLIJobOutput_NoStreamNoPrinter(t *testing.T) { + var buf bytes.Buffer + // streamOutput=false, printer=nil => returns nil without writing. + if err := writeCachedCLIJobOutput("cached", &buf, nil, 0, false); err != nil { + t.Fatalf("writeCachedCLIJobOutput: %v", err) + } + if buf.Len() != 0 { + t.Fatalf("expected no output, got %q", buf.String()) + } +} + +// errWriter is an io.Writer that always returns an error. +type errWriter struct{ err error } + +func (e errWriter) Write([]byte) (int, error) { return 0, e.err } + +func TestWriteCLIJobOutput_WriteError(t *testing.T) { + w := errWriter{err: errors.New("write fail")} + res := &cliJobResult{provider: "p", model: "m", output: "out"} + if err := writeCLIJobOutput(w, res, true); err == nil { + t.Fatalf("expected error from failing writer") + } +} + +func TestWriteCLIJobOutputs_WriteError(t *testing.T) { + w := errWriter{err: errors.New("write fail")} + results := []*cliJobResult{{provider: "p", model: "m", output: "out"}} + if err := writeCLIJobOutputs(w, results); err == nil { + t.Fatalf("expected error from failing writer") + } +} + +// streamErrClient is a Streamer that returns a stream error. +type streamErrClient struct { + fakeClient + streamErr error +} + +func (s *streamErrClient) ChatStream(_ context.Context, _ []llm.Message, _ func(string), _ ...llm.RequestOption) error { + return s.streamErr +} + +func TestRunStreamingChat_StreamError(t *testing.T) { + client := &streamErrClient{ + fakeClient: fakeClient{name: "p", model: "m"}, + streamErr: fmt.Errorf("stream broken"), + } + var out bytes.Buffer + _, err := runStreamingChat(context.Background(), client, nil, nil, &out) + if err == nil || !strings.Contains(err.Error(), "stream broken") { + t.Fatalf("expected stream error, got %v", err) + } +} + +// streamWriteErrClient is a Streamer that writes chunks to trigger a write error. +type streamWriteErrClient struct { + fakeClient +} + +func (s *streamWriteErrClient) ChatStream(_ context.Context, _ []llm.Message, onDelta func(string), _ ...llm.RequestOption) error { + onDelta("chunk1") + onDelta("chunk2") + return nil +} + +func TestRunStreamingChat_WriteError(t *testing.T) { + client := &streamWriteErrClient{fakeClient: fakeClient{name: "p", model: "m"}} + w := errWriter{err: errors.New("write fail")} + _, err := runStreamingChat(context.Background(), client, nil, nil, w) + if err == nil || !strings.Contains(err.Error(), "write fail") { + t.Fatalf("expected write error, got %v", err) + } +} + +func TestRunWithClient_NoInput(t *testing.T) { + var out, errb bytes.Buffer + err := RunWithClient(context.Background(), nil, strings.NewReader(""), &out, &errb, &fakeClient{name: "p", model: "m", resp: "out"}) + if err == nil { + t.Fatalf("expected error for no input") + } + if !strings.Contains(errb.String(), "no input provided") { + t.Fatalf("expected no-input error message, got %q", errb.String()) + } +} + +func TestRunWithClient_Success(t *testing.T) { + var out, errb bytes.Buffer + client := &fakeClient{name: "p", model: "m", resp: "result"} + err := RunWithClient(context.Background(), []string{"hello"}, strings.NewReader(""), &out, &errb, client) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.String() != "result" { + t.Fatalf("stdout = %q, want result", out.String()) + } + if !strings.Contains(errb.String(), "provider=p model=m") { + t.Fatalf("expected summary in stderr, got %q", errb.String()) + } +} + +func TestEffectiveModel_Empty(t *testing.T) { + client := &fakeClient{name: "p", model: "default-model"} + req := requestArgs{model: ""} + if got := effectiveModel(req, client); got != "default-model" { + t.Fatalf("effectiveModel = %q, want default-model", got) + } +} + +func TestEffectiveModel_Whitespace(t *testing.T) { + client := &fakeClient{name: "p", model: "default-model"} + req := requestArgs{model: " "} + if got := effectiveModel(req, client); got != "default-model" { + t.Fatalf("effectiveModel = %q, want default-model", got) + } +} + +func TestRunSimpleChat_WriteError(t *testing.T) { + client := &fakeClient{name: "p", model: "m", resp: "ok"} + w := errWriter{err: errors.New("write fail")} + _, err := runSimpleChat(context.Background(), client, nil, nil, w) + if err == nil || !strings.Contains(err.Error(), "write fail") { + t.Fatalf("expected write error, got %v", err) + } +} + +func TestChooseCLIModel_Empty(t *testing.T) { + if got := chooseCLIModel("", "fallback"); got != "fallback" { + t.Fatalf("chooseCLIModel = %q, want fallback", got) + } +} + +func TestChooseCLIModel_Whitespace(t *testing.T) { + if got := chooseCLIModel(" ", "fallback"); got != "fallback" { + t.Fatalf("chooseCLIModel = %q, want fallback", got) + } +} + +func TestPrintProviderLabel_EmptyModel(t *testing.T) { + var buf bytes.Buffer + printProviderLabel(&buf, "p", "") + if buf.Len() != 0 { + t.Fatalf("expected no output for empty model, got %q", buf.String()) + } +} + +func TestPrintProviderLabel_WhitespaceModel(t *testing.T) { + var buf bytes.Buffer + printProviderLabel(&buf, "p", " ") + if buf.Len() != 0 { + t.Fatalf("expected no output for whitespace model, got %q", buf.String()) + } +} + +func TestCacheHitSummary_NegativeAge(t *testing.T) { + got := cacheHitSummary("p", "m", -5) + if !strings.Contains(got, "cache hit") || !strings.Contains(got, "age=0s") { + t.Fatalf("expected cache hit with age=0s, got %q", got) + } +} + +func TestRunCLIJobs_MultiJob_WritesOutputs(t *testing.T) { + // runCLIJobs with multiple jobs should call writeCLIJobOutputs + // (the non-streaming, non-printer path). + oldNew := newClientFromApp + defer func() { newClientFromApp = oldNew }() + newClientFromApp = func(cfg appconfig.App) (llm.Client, error) { + return &fakeClient{name: cfg.Provider, model: "m", resp: "out-" + cfg.Provider}, nil + } + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + jobs := []cliJob{ + {index: 0, provider: "a", cfg: appconfig.App{Provider: "a", OllamaBaseURL: "http://x", OllamaModel: "m"}, req: requestArgs{model: "m"}}, + {index: 1, provider: "b", cfg: appconfig.App{Provider: "b", OllamaBaseURL: "http://x", OllamaModel: "m"}, req: requestArgs{model: "m"}}, + } + msgs := buildMessages("hello") + var stdout, stderr bytes.Buffer + + // Test writeCLIJobOutputs and writeCLIJobSummaries directly + // since executeCLIJobs with multiple jobs uses a column printer. + _ = jobs + _ = msgs + results := []*cliJobResult{ + {provider: "a", model: "m1", output: "first"}, + {provider: "b", model: "m2", output: "second"}, + } + if err := writeCLIJobOutputs(&stdout, results); err != nil { + t.Fatalf("writeCLIJobOutputs: %v", err) + } + if err := writeCLIJobSummaries(&stderr, results); err != nil { + t.Fatalf("writeCLIJobSummaries: %v", err) + } + + got := stdout.String() + if !strings.Contains(got, "=== a:m1 ===") || !strings.Contains(got, "=== b:m2 ===") { + t.Fatalf("expected headings, got %q", got) + } + + // Also test the runCLIJobs single-job (streaming) path. + singleJobs := []cliJob{ + {index: 0, provider: "a", cfg: appconfig.App{Provider: "a", OllamaBaseURL: "http://x", OllamaModel: "m"}, req: requestArgs{model: "m"}}, + } + stdout.Reset() + stderr.Reset() + if err := runCLIJobs(context.Background(), singleJobs, msgs, "hello", &stdout, &stderr); err != nil { + t.Fatalf("runCLIJobs single: %v", err) + } + if !strings.Contains(stdout.String(), "out-a") { + t.Fatalf("expected single job output, got %q", stdout.String()) + } +} + +func TestWithCLISelection_NilContext(t *testing.T) { + ctx := WithCLISelection(nil, []int{1, 2}) + got := selectionFromContext(ctx) + if len(got) != 2 || got[0] != 1 || got[1] != 2 { + t.Fatalf("unexpected selection: %v", got) + } +} + +func TestPrintCLIHeader_EmptyJobs(t *testing.T) { + var buf bytes.Buffer + printCLIHeader(&buf, nil, nil) + if buf.Len() != 0 { + t.Fatalf("expected no output for empty jobs, got %q", buf.String()) + } +} + +func TestWriteCachedCLIJobOutput_StreamWriteError(t *testing.T) { + w := errWriter{err: errors.New("write fail")} + err := writeCachedCLIJobOutput("data", w, nil, 0, true) + if err == nil || !strings.Contains(err.Error(), "write fail") { + t.Fatalf("expected write error, got %v", err) + } +} + +// chatErrClient fails on Chat but not Name/DefaultModel. +type chatErrClient struct { + fakeClient + chatErr error +} + +func (c *chatErrClient) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { + return "", c.chatErr +} + +func TestRunSimpleChat_ChatError(t *testing.T) { + client := &chatErrClient{ + fakeClient: fakeClient{name: "p", model: "m"}, + chatErr: fmt.Errorf("chat broken"), + } + var out bytes.Buffer + _, err := runSimpleChat(context.Background(), client, nil, nil, &out) + if err == nil || !strings.Contains(err.Error(), "chat broken") { + t.Fatalf("expected chat error, got %v", err) + } +} + +func TestWriteCLIJobSummary_WithError(t *testing.T) { + var buf bytes.Buffer + res := &cliJobResult{provider: "p", model: "m", err: fmt.Errorf("boom"), summary: ""} + if err := writeCLIJobSummary(&buf, res); err != nil { + t.Fatalf("writeCLIJobSummary: %v", err) + } + if !strings.Contains(buf.String(), "boom") || !strings.Contains(buf.String(), "provider=p model=m") { + t.Fatalf("expected error info, got %q", buf.String()) + } +} + +func TestWriteCLIJobSummaries_FirstError(t *testing.T) { + results := []*cliJobResult{ + {provider: "a", model: "m", err: nil}, + {provider: "b", model: "m", err: fmt.Errorf("fail")}, + } + var buf bytes.Buffer + err := writeCLIJobSummaries(&buf, results) + if err == nil || !strings.Contains(err.Error(), "fail") { + t.Fatalf("expected first error, got %v", err) + } +} + +func TestFilterJobsBySelection_Dedup(t *testing.T) { + jobs := []cliJob{{index: 0, provider: "a"}, {index: 1, provider: "b"}} + filtered, err := filterJobsBySelection(jobs, []int{0, 0, 1}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(filtered) != 2 { + t.Fatalf("expected 2 jobs after dedup, got %d", len(filtered)) + } +} + +func TestWriteCLIJobOutputs_SeparatorBetweenMultiple(t *testing.T) { + var buf bytes.Buffer + results := []*cliJobResult{ + {provider: "a", model: "m", output: "one\n"}, + nil, + {provider: "b", model: "m", output: "two\n"}, + } + if err := writeCLIJobOutputs(&buf, results); err != nil { + t.Fatalf("writeCLIJobOutputs: %v", err) + } + got := buf.String() + // Should have a blank line separator between the two non-nil results. + if !strings.Contains(got, "one\n\n") { + t.Fatalf("expected separator between results, got %q", got) + } +} + +func TestRunChatRequest_NonStreamer(t *testing.T) { + client := &fakeClient{name: "p", model: "m", resp: "hello"} + var out bytes.Buffer + got, err := runChatRequest(context.Background(), client, requestArgs{model: "m"}, nil, &out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "hello" { + t.Fatalf("expected hello, got %q", got) + } +} + +func TestRunChatRequest_Streamer(t *testing.T) { + client := &fakeStreamer{ + fakeClient: fakeClient{name: "p", model: "m"}, + chunks: []string{"a", "b"}, + } + var out bytes.Buffer + got, err := runChatRequest(context.Background(), client, requestArgs{model: "m"}, nil, &out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "ab" { + t.Fatalf("expected ab, got %q", got) + } +} + +func TestSelectionFromContext_Nil(t *testing.T) { + if got := selectionFromContext(nil); got != nil { + t.Fatalf("expected nil, got %v", got) + } +} + +func TestSelectionFromContext_NoValue(t *testing.T) { + if got := selectionFromContext(context.Background()); got != nil { + t.Fatalf("expected nil, got %v", got) + } +} + +func TestFilterJobsBySelection_Empty(t *testing.T) { + jobs := []cliJob{{index: 0, provider: "a"}} + filtered, err := filterJobsBySelection(jobs, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(filtered) != 1 { + t.Fatalf("expected original jobs, got %d", len(filtered)) + } +} diff --git a/internal/hexaimcp/run_test.go b/internal/hexaimcp/run_test.go index 3c3f9d8..7883efd 100644 --- a/internal/hexaimcp/run_test.go +++ b/internal/hexaimcp/run_test.go @@ -340,3 +340,317 @@ func TestRunWithFactory_ServerError(t *testing.T) { t.Errorf("RunWithFactory() error = %v, want to contain 'server error'", err) } } + +// TestRunWithFactory_LoggerError verifies that a bad log path propagates as an error. +func TestRunWithFactory_LoggerError(t *testing.T) { + // Use /dev/null/impossible as log path — directory creation will fail + // because /dev/null is a file, not a directory. + badLogPath := "/dev/null/impossible/test.log" + + mockFactory := func(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore, syncer mcp.SlashCommandSyncer) ServerRunner { + return &mockServerRunner{} + } + + err := RunWithFactory(badLogPath, "", &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}, mockFactory) + if err == nil { + t.Fatal("expected error for invalid log path, got nil") + } + if !strings.Contains(err.Error(), "cannot setup logger") { + t.Errorf("error = %v, want to contain 'cannot setup logger'", err) + } +} + +// TestRunWithFactory_StderrLogger verifies RunWithFactory works when logPath +// is empty (logger writes to stderr, defer close branch is a no-op). +func TestRunWithFactory_StderrLogger(t *testing.T) { + tmpDir := t.TempDir() + + mockFactory := func(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore, syncer mcp.SlashCommandSyncer) ServerRunner { + return &mockServerRunner{} + } + + oldEnv := os.Getenv("HEXAI_MCP_PROMPTS_DIR") + defer os.Setenv("HEXAI_MCP_PROMPTS_DIR", oldEnv) + os.Setenv("HEXAI_MCP_PROMPTS_DIR", tmpDir) + + // Empty logPath causes logger to write to stderr (no file to close) + err := RunWithFactory("", "", &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}, mockFactory) + if err != nil { + t.Fatalf("RunWithFactory() error = %v", err) + } +} + +// TestRun_CallsDefaultFactory verifies the Run() entry point invokes +// RunWithFactory with the defaultServerFactory. The real server reads +// from stdin until EOF; with an empty buffer it returns immediately. +func TestRun_CallsDefaultFactory(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "test.log") + + oldEnv := os.Getenv("HEXAI_MCP_PROMPTS_DIR") + defer os.Setenv("HEXAI_MCP_PROMPTS_DIR", oldEnv) + os.Setenv("HEXAI_MCP_PROMPTS_DIR", tmpDir) + + // Run with empty stdin — the real server hits EOF and exits cleanly. + // This exercises the full Run -> RunWithFactory -> defaultServerFactory path. + err := Run(logPath, "", &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}) + // The server may return nil or an error depending on how it handles EOF; + // the important thing is that Run() itself does not panic. + _ = err +} + +// TestSetupLogger_InvalidPath verifies setupLogger returns an error when +// the log directory cannot be created. +func TestSetupLogger_InvalidPath(t *testing.T) { + // /dev/null is a file, so creating a subdirectory under it fails + _, err := setupLogger("/dev/null/subdir/test.log") + if err == nil { + t.Fatal("expected error for invalid log path, got nil") + } + if !strings.Contains(err.Error(), "cannot create log directory") { + t.Errorf("error = %v, want to contain 'cannot create log directory'", err) + } +} + +// TestSetupLogger_WhitespacePath verifies that a whitespace-only path +// falls back to stderr logging. +func TestSetupLogger_WhitespacePath(t *testing.T) { + logger, err := setupLogger(" ") + if err != nil { + t.Fatalf("setupLogger() error = %v", err) + } + if logger == nil { + t.Fatal("setupLogger() returned nil logger") + } +} + +// TestGetPromptsDir_XDGDataHome verifies getPromptsDir uses XDG_DATA_HOME +// when set (covers the branch where XDG_DATA_HOME is non-empty). +func TestGetPromptsDir_XDGDataHome(t *testing.T) { + oldPrompts := os.Getenv("HEXAI_MCP_PROMPTS_DIR") + defer os.Setenv("HEXAI_MCP_PROMPTS_DIR", oldPrompts) + os.Setenv("HEXAI_MCP_PROMPTS_DIR", "") + + oldXDG := os.Getenv("XDG_ |
