Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .devcontainer/post_create.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ mise exec -- go mod download
mise exec -- go install -v golang.org/x/tools/gopls@latest
mise exec -- go install -v github.com/go-delve/delve/cmd/dlv@latest

uv tool install pre-commit
mise exec -- prek install

uv tool install -p 3.13 serena-agent
pre-commit install --install-hooks

if [[ -f ".devcontainer/post_create.local.sh" ]]; then
source ".devcontainer/post_create.local.sh"
Expand Down
24 changes: 24 additions & 0 deletions cmd/mint/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ func TestCanonicalLangTag(t *testing.T) {
{"script title-cased", "zh-hant", "zh-Hant"},
{"already canonical is idempotent", "zh-TW", "zh-TW"},
{"mixed case normalized", "ZH-Tw", "zh-TW"},
{"non 2/4-length subtag lowercased", "zh-YUE", "zh-yue"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down Expand Up @@ -289,6 +290,8 @@ func TestGetSystemLanguage(t *testing.T) {
{"LC_ALL used when LANG empty", "", "ja_JP.UTF-8", "ja"},
{"LC_ALL overrides LANG when both set", "en_US.UTF-8", "ja_JP.UTF-8", "ja"},
{"both empty returns empty string", "", "", ""},
{"LC_ALL is C, falls through to LANG", "de_DE.UTF-8", "C", "de"},
{"LC_ALL is POSIX, falls through to LANG", "ko_KR.UTF-8", "POSIX", "ko"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down Expand Up @@ -345,6 +348,27 @@ func TestResolveInputStdinBlank(t *testing.T) {
}
}

// A read error on stdin (e.g. a closed file descriptor) must surface as an
// error rather than being swallowed or treated as empty input.
func TestResolveInputStdinReadError(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}

_ = r.Close() // reading from a closed file returns an error
_ = w.Close()

old := os.Stdin

os.Stdin = r
defer func() { os.Stdin = old }()

if _, err := resolveInput(nil); err == nil {
t.Error("expected error when stdin read fails")
}
}

// captureStdout replaces os.Stdout with a pipe and returns a function that
// restores os.Stdout and returns whatever was written.
func captureStdout(t *testing.T) func() string {
Expand Down
47 changes: 47 additions & 0 deletions internal/httpx/httpx_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright 2026 The Mint Authors.

package httpx_test

import (
"net/http"
"testing"

"github.com/min0625/mint/internal/httpx"
)

func TestNew(t *testing.T) {
c := httpx.New()
if c == nil {
t.Fatal("expected non-nil client")
}

// No overall timeout: a slow or cold-starting backend must be allowed to
// stream for as long as it needs; cancellation is the caller's job via ctx.
if c.Timeout != 0 {
t.Errorf("Timeout = %v, want 0 (no overall timeout)", c.Timeout)
}

tr, ok := c.Transport.(*http.Transport)
if !ok {
t.Fatalf("Transport = %T, want *http.Transport", c.Transport)
}

// Connection setup (DNS/dial, TLS handshake) must still be bounded so an
// unreachable endpoint fails fast instead of hanging indefinitely.
if tr.TLSHandshakeTimeout == 0 {
t.Error("TLSHandshakeTimeout must be set")
}

if !tr.ForceAttemptHTTP2 {
t.Error("ForceAttemptHTTP2 = false, want true")
}
}

func TestNewReturnsDistinctClients(t *testing.T) {
a := httpx.New()
b := httpx.New()

if a == b {
t.Error("expected each call to New to return a distinct client")
}
}
31 changes: 31 additions & 0 deletions internal/provider/anthropic/anthropic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,37 @@ func TestCompleteReturnsErrorOnNon200(t *testing.T) {
}
}

// A malformed data line (e.g. truncated by a flaky proxy) must not abort the
// whole stream; it is skipped and the well-formed chunks around it still
// reach the caller.
func TestCompleteSkipsMalformedDataLine(t *testing.T) {
const sse = `event: content_block_delta
data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"}}

data: {not valid json}

event: content_block_delta
data: {"type":"content_block_delta","delta":{"type":"text_delta","text":" world"}}

data: {"type":"message_stop"}
`

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(sse))
}))
defer srv.Close()

var sb strings.Builder
if _, err := anthropic.New("k", srv.URL, "").Complete(t.Context(), "sys", "usr", &sb); err != nil {
t.Fatalf("Complete returned error: %v", err)
}

if got, want := sb.String(), "Hello world\n"; got != want {
t.Errorf("output = %q, want %q", got, want)
}
}

func TestCompleteRoleSeparation(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
Expand Down
27 changes: 27 additions & 0 deletions internal/provider/googlegenai/google_genai_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,33 @@ func TestCompleteReturnsErrorOnNon200(t *testing.T) {
}
}

// A malformed data line (e.g. truncated by a flaky proxy) must not abort the
// whole stream; it is skipped and the well-formed chunks around it still
// reach the caller.
func TestCompleteSkipsMalformedDataLine(t *testing.T) {
const sse = `data: {"candidates":[{"content":{"parts":[{"text":"Hello"}]}}]}

data: {not valid json}

data: {"candidates":[{"content":{"parts":[{"text":" world"}]}}]}
`

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(sse))
}))
defer srv.Close()

var sb strings.Builder
if _, err := googlegenai.New("k", srv.URL, "").Complete(t.Context(), "sys", "usr", &sb); err != nil {
t.Fatalf("Complete returned error: %v", err)
}

if got, want := sb.String(), "Hello world\n"; got != want {
t.Errorf("output = %q, want %q", got, want)
}
}

func TestCompleteRoleSeparation(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
Expand Down
29 changes: 29 additions & 0 deletions internal/provider/openai/openai_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,35 @@ func TestCompleteSurfaces400(t *testing.T) {
}
}

// A malformed data line (e.g. truncated by a flaky proxy) must not abort the
// whole stream; it is skipped and the well-formed chunks around it still
// reach the caller.
func TestCompleteSkipsMalformedDataLine(t *testing.T) {
const sse = `data: {"choices":[{"delta":{"content":"Hello"}}]}

data: {not valid json}

data: {"choices":[{"delta":{"content":" world"}}]}

data: [DONE]
`

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(sse))
}))
defer srv.Close()

var sb strings.Builder
if _, err := openai.New("k", srv.URL, "").Complete(t.Context(), "sys", "usr", &sb); err != nil {
t.Fatalf("Complete returned error: %v", err)
}

if got, want := sb.String(), "Hello world\n"; got != want {
t.Errorf("output = %q, want %q", got, want)
}
}

// The model may stream content that already ends in a newline; the client must
// not append a second one, so output ends with exactly one trailing newline.
func TestCompleteDoesNotDoubleTrailingNewline(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
go = "1.26.4"
golangci-lint = "2.12.2"
goreleaser = "2.16.0"
prek = "0.4.6"
Loading