From 0e6bd9daebfb3f9752eb389da02ef8bf2aeee235 Mon Sep 17 00:00:00 2001 From: Min Huang <70873102+min0625@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:02:47 +0000 Subject: [PATCH] test: cover SSE malformed-line handling, httpx client config, stdin/locale edge cases; switch devcontainer to prek Adds regression coverage for provider streaming (malformed SSE data lines must not abort the stream), the shared httpx client's timeout/transport config, a stdin read-error path, and locale fallback when LC_ALL is C or POSIX. Also swaps pre-commit for prek in the devcontainer setup. --- .devcontainer/post_create.sh | 4 +- cmd/mint/main_test.go | 24 ++++++++++ internal/httpx/httpx_test.go | 47 +++++++++++++++++++ internal/provider/anthropic/anthropic_test.go | 31 ++++++++++++ .../provider/googlegenai/google_genai_test.go | 27 +++++++++++ internal/provider/openai/openai_test.go | 29 ++++++++++++ mise.toml | 1 + 7 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 internal/httpx/httpx_test.go diff --git a/.devcontainer/post_create.sh b/.devcontainer/post_create.sh index 9f8487c..38515ab 100644 --- a/.devcontainer/post_create.sh +++ b/.devcontainer/post_create.sh @@ -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" diff --git a/cmd/mint/main_test.go b/cmd/mint/main_test.go index 93ed197..28e5630 100644 --- a/cmd/mint/main_test.go +++ b/cmd/mint/main_test.go @@ -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) { @@ -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) { @@ -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 { diff --git a/internal/httpx/httpx_test.go b/internal/httpx/httpx_test.go new file mode 100644 index 0000000..f9b057c --- /dev/null +++ b/internal/httpx/httpx_test.go @@ -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") + } +} diff --git a/internal/provider/anthropic/anthropic_test.go b/internal/provider/anthropic/anthropic_test.go index c39e180..d6be027 100644 --- a/internal/provider/anthropic/anthropic_test.go +++ b/internal/provider/anthropic/anthropic_test.go @@ -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) diff --git a/internal/provider/googlegenai/google_genai_test.go b/internal/provider/googlegenai/google_genai_test.go index f7a8fa2..bb2770a 100644 --- a/internal/provider/googlegenai/google_genai_test.go +++ b/internal/provider/googlegenai/google_genai_test.go @@ -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) diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index 4aa69b9..274d90c 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -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) { diff --git a/mise.toml b/mise.toml index 032fa1f..45f9281 100644 --- a/mise.toml +++ b/mise.toml @@ -2,3 +2,4 @@ go = "1.26.4" golangci-lint = "2.12.2" goreleaser = "2.16.0" +prek = "0.4.6"