Files
MyGoNavi/internal/ai/provider/claude_cli_test.go
2026-06-22 15:12:42 +08:00

580 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package provider
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"GoNavi-Wails/internal/ai"
)
func TestBuildClaudeCLIEnv_IncludesAnthropicProxyEnv(t *testing.T) {
env, err := buildClaudeCLIEnv(ai.ProviderConfig{
BaseURL: "https://proxy.example.com/",
APIKey: "sk-test",
}, []string{"PATH=/usr/bin"}, "darwin", func(name string) (string, error) {
return "", errors.New("unexpected lookup")
}, func(path string) bool {
return false
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got := envValue(env, "ANTHROPIC_BASE_URL"); got != "https://proxy.example.com" {
t.Fatalf("expected trimmed base url, got %q", got)
}
if got := envValue(env, "ANTHROPIC_API_KEY"); got != "sk-test" {
t.Fatalf("expected api key in env, got %q", got)
}
if got := envValue(env, "ANTHROPIC_AUTH_TOKEN"); got != "sk-test" {
t.Fatalf("expected auth token in env, got %q", got)
}
}
func TestBuildClaudeCLIRequestLogBodyRedactsSecretsAndKeepsRequestShape(t *testing.T) {
prompt := "请分析订单表。临时凭证 Bearer abcdefghijklmnopqrstuvwxyz另一个 key 是 sk-live-abcdefghijklmnopqrstuvwxyz"
args := []string{"-p", prompt, "--output-format", "stream-json", "--model", "claude-sonnet"}
originalPromptArg := args[1]
body := buildClaudeCLIRequestLogBody("stream-json", args, prompt, ai.ProviderConfig{
BaseURL: "https://proxy.example.com/api/anthropic?key=proxy-secret&alt=sse",
APIKey: "sk-config-secret-1234567890",
Model: "claude-sonnet",
}, ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: prompt}},
Tools: []ai.Tool{{
Type: "function",
Function: ai.ToolFunction{
Name: "inspect_ai_upstream_logs",
Description: "读取 AI 上游请求日志",
},
}},
})
got := formatAIUpstreamRequestLogBody(body)
for _, want := range []string{
`"command":"claude"`,
`"output_format":"stream-json"`,
`"model":"claude-sonnet"`,
`"has_api_key":true`,
`"message_count":1`,
`"tool_count":1`,
`"[prompt logged separately]"`,
`inspect_ai_upstream_logs`,
`key=%5BREDACTED%5D`,
`[REDACTED]`,
} {
if !strings.Contains(got, want) {
t.Fatalf("expected Claude CLI request log body to contain %q, got %s", want, got)
}
}
for _, leaked := range []string{
"proxy-secret",
"sk-config-secret",
"Bearer abcdefghijklmnopqrstuvwxyz",
"sk-live-abcdefghijklmnopqrstuvwxyz",
} {
if strings.Contains(got, leaked) {
t.Fatalf("Claude CLI request log body leaked %q: %s", leaked, got)
}
}
if args[1] != originalPromptArg {
t.Fatalf("expected original args to remain unchanged, got %q", args[1])
}
}
func TestBuildClaudeCLIEnv_UsesDetectedGitBashOnWindows(t *testing.T) {
env, err := buildClaudeCLIEnv(ai.ProviderConfig{}, []string{"ProgramFiles=C:\\Program Files"}, "windows", func(name string) (string, error) {
switch name {
case "bash.exe":
return "", errors.New("not found")
case "bash":
return "", errors.New("not found")
case "git.exe":
return "C:\\Program Files\\Git\\cmd\\git.exe", nil
default:
return "", errors.New("unexpected lookup")
}
}, func(path string) bool {
return path == `C:\Program Files\Git\bin\bash.exe`
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got := envValue(env, "CLAUDE_CODE_GIT_BASH_PATH"); got != `C:\Program Files\Git\bin\bash.exe` {
t.Fatalf("expected detected git bash path, got %q", got)
}
}
func TestBuildClaudeCLIEnv_ReturnsActionableErrorWhenGitBashMissingOnWindows(t *testing.T) {
_, err := buildClaudeCLIEnv(ai.ProviderConfig{}, []string{"ProgramFiles=C:\\Program Files"}, "windows", func(name string) (string, error) {
return "", errors.New("not found")
}, func(path string) bool {
return false
})
if err == nil {
t.Fatal("expected error when git bash is missing on windows")
}
if !strings.Contains(err.Error(), "git-bash") {
t.Fatalf("expected git-bash hint, got %v", err)
}
if !strings.Contains(err.Error(), "CLAUDE_CODE_GIT_BASH_PATH") {
t.Fatalf("expected env var hint, got %v", err)
}
for _, notWant := range []string{
"在 Windows 下需要",
"请安装",
"如果已安装",
"指向",
} {
if strings.Contains(err.Error(), notWant) {
t.Fatalf("expected English git-bash error, got %q", err.Error())
}
}
}
func TestClaudeCLIProviderValidateReturnsEnglishInstallHintWhenCommandMissing(t *testing.T) {
originalLookPath := claudeLookPath
claudeLookPath = func(name string) (string, error) {
return "", errors.New("not found")
}
defer func() {
claudeLookPath = originalLookPath
}()
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{})
if err != nil {
t.Fatalf("create provider failed: %v", err)
}
err = provider.Validate()
if err == nil {
t.Fatal("expected missing claude command error")
}
const want = "claude command was not found; install Claude Code CLI first: npm install -g @anthropic-ai/claude-code"
if err.Error() != want {
t.Fatalf("expected %q, got %q", want, err.Error())
}
if strings.Contains(err.Error(), "未找到 claude 命令") || strings.Contains(err.Error(), "请先安装") {
t.Fatalf("expected English missing claude error, got %q", err.Error())
}
}
func TestResolveClaudeCodeGitBashPathReturnsEnglishErrors(t *testing.T) {
_, err := resolveClaudeCodeGitBashPath(
[]string{`CLAUDE_CODE_GIT_BASH_PATH=C:\missing\bash.exe`},
"windows",
func(name string) (string, error) { return "", errors.New("not found") },
func(path string) bool { return false },
)
if err == nil {
t.Fatal("expected configured git-bash path error")
}
const want = `Claude Code CLI requires git-bash on Windows, but CLAUDE_CODE_GIT_BASH_PATH points to a missing bash.exe: C:\missing\bash.exe`
if err.Error() != want {
t.Fatalf("expected %q, got %q", want, err.Error())
}
if strings.Contains(err.Error(), "在 Windows 下需要") || strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "指向") {
t.Fatalf("expected English configured git-bash error, got %q", err.Error())
}
}
func TestClaudeCLIProvider_ChatTimesOutWhenCommandDoesNotFinish(t *testing.T) {
fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\nsleep 5\n")
restore := overrideClaudeCLIForTest(t, fakeClaude)
defer restore()
originalRequestTimeout := claudeCLIRequestTimeout
claudeCLIRequestTimeout = 200 * time.Millisecond
defer func() {
claudeCLIRequestTimeout = originalRequestTimeout
}()
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
APIKey: "sk-test",
Model: "qwen3.5-plus",
})
if err != nil {
t.Fatalf("unexpected provider error: %v", err)
}
start := time.Now()
_, err = provider.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
})
if err == nil {
t.Fatal("expected chat timeout error")
}
assertClaudeCLIRuntimeErrorIsEnglish(t, err.Error())
if strings.Contains(err.Error(), "timed out") && time.Since(start) < 200*time.Millisecond {
t.Fatalf("expected timeout path to wait for configured deadline, took %s", time.Since(start))
}
}
func TestClaudeCLIProvider_ChatStreamUsesRequestTimeoutWhenNoMeaningfulResponseArrives(t *testing.T) {
fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"system\",\"subtype\":\"init\"}'\nexec sleep 5\n")
restore := overrideClaudeCLIForTest(t, fakeClaude)
defer restore()
originalRequestTimeout := claudeCLIRequestTimeout
claudeCLIRequestTimeout = 200 * time.Millisecond
defer func() {
claudeCLIRequestTimeout = originalRequestTimeout
}()
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
APIKey: "sk-test",
Model: "qwen3.5-plus",
})
if err != nil {
t.Fatalf("unexpected provider error: %v", err)
}
var chunks []ai.StreamChunk
err = provider.ChatStream(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
}, func(chunk ai.StreamChunk) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("expected stream provider to report timeout via callback, got %v", err)
}
if len(chunks) == 0 {
t.Fatal("expected timeout chunk")
}
lastChunk := chunks[len(chunks)-1]
if !lastChunk.Done {
t.Fatalf("expected timeout chunk to terminate stream, got %#v", lastChunk)
}
assertClaudeCLIRuntimeErrorIsEnglish(t, lastChunk.Error)
}
func TestClaudeCLIRuntimeErrorsStayEnglish(t *testing.T) {
message, hasError := extractClaudeCLIEventError(cliStreamEvent{Type: "error"})
if !hasError {
t.Fatal("expected fallback error")
}
if message != "claude CLI returned an unknown error" {
t.Fatalf("expected English unknown error, got %q", message)
}
authMessage, hasAuthError := extractClaudeCLISystemRetryError(cliStreamEvent{
Type: "system",
Subtype: "api_retry",
ErrorStatus: 401,
Error: cliStreamEventError{Message: "authentication_failed"},
})
if !hasAuthError {
t.Fatal("expected auth retry error")
}
if authMessage != "claude CLI authentication failed (HTTP 401): authentication_failed" {
t.Fatalf("expected English auth retry error, got %q", authMessage)
}
source, err := os.ReadFile("claude_cli.go")
if err != nil {
t.Fatalf("read claude_cli.go: %v", err)
}
for _, notWant := range []string{
"执行超时",
"执行失败",
"返回错误",
"创建 stdout 管道失败",
"启动 claude CLI 失败",
"鉴权失败",
"异常退出",
"返回未知错误",
} {
if strings.Contains(string(source), notWant) {
t.Fatalf("expected Claude CLI runtime wrappers to stay English, found %q", notWant)
}
}
}
func assertClaudeCLIRuntimeErrorIsEnglish(t *testing.T, message string) {
t.Helper()
if strings.TrimSpace(message) == "" {
t.Fatal("expected runtime error message")
}
for _, notWant := range []string{
"执行超时",
"执行失败",
"返回错误",
"创建 stdout 管道失败",
"启动 claude CLI 失败",
"鉴权失败",
"异常退出",
"返回未知错误",
} {
if strings.Contains(message, notWant) {
t.Fatalf("expected English Claude CLI runtime error, got %q", message)
}
}
}
func TestClaudeCLIProvider_ChatStreamAllowsDelayedMeaningfulResponse(t *testing.T) {
fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"system\",\"subtype\":\"init\"}'\nsleep 0.2\necho '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"OK\"}]}}'\necho '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"OK\"}'\n")
restore := overrideClaudeCLIForTest(t, fakeClaude)
defer restore()
originalRequestTimeout := claudeCLIRequestTimeout
claudeCLIRequestTimeout = 1 * time.Second
defer func() {
claudeCLIRequestTimeout = originalRequestTimeout
}()
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
APIKey: "sk-test",
Model: "qwen3.5-plus",
})
if err != nil {
t.Fatalf("unexpected provider error: %v", err)
}
var chunks []ai.StreamChunk
err = provider.ChatStream(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
}, func(chunk ai.StreamChunk) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("expected delayed response to complete via callback, got %v", err)
}
if len(chunks) == 0 {
t.Fatal("expected delayed response chunks")
}
if chunks[0].Content != "OK" {
t.Fatalf("expected delayed content chunk, got %#v", chunks)
}
if !chunks[len(chunks)-1].Done {
t.Fatalf("expected terminal done chunk, got %#v", chunks[len(chunks)-1])
}
}
func TestClaudeCLIProvider_ChatReturnsErrorWhenJSONResponseIsError(t *testing.T) {
fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":true,\"result\":\"API Error: Unable to connect to API (ECONNRESET)\",\"error\":\"unknown\"}'\n")
restore := overrideClaudeCLIForTest(t, fakeClaude)
defer restore()
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
APIKey: "sk-test",
Model: "qwen3.5-plus",
})
if err != nil {
t.Fatalf("unexpected provider error: %v", err)
}
_, err = provider.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
})
if err == nil {
t.Fatal("expected chat error when CLI JSON marks request as failed")
}
if !strings.Contains(err.Error(), "Unable to connect to API") {
t.Fatalf("expected upstream API error, got %v", err)
}
}
func TestClaudeCLIProvider_ChatStreamReportsAssistantErrorEvent(t *testing.T) {
fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"assistant\",\"is_error\":true,\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"API Error: Unable to connect to API (ECONNRESET)\"}]},\"error\":\"unknown\"}'\n")
restore := overrideClaudeCLIForTest(t, fakeClaude)
defer restore()
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
APIKey: "sk-test",
Model: "qwen3.5-plus",
})
if err != nil {
t.Fatalf("unexpected provider error: %v", err)
}
var chunks []ai.StreamChunk
err = provider.ChatStream(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
}, func(chunk ai.StreamChunk) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("expected stream provider to report error via callback, got %v", err)
}
if len(chunks) != 1 {
t.Fatalf("expected a single terminal error chunk, got %#v", chunks)
}
if chunks[0].Content != "" {
t.Fatalf("expected assistant error event to avoid content output, got %#v", chunks[0])
}
if !chunks[0].Done || !strings.Contains(chunks[0].Error, "Unable to connect to API") {
t.Fatalf("expected upstream API error chunk, got %#v", chunks[0])
}
}
func TestClaudeCLIProvider_ChatStreamReportsResultErrorEvent(t *testing.T) {
fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":true,\"result\":\"API Error: Unable to connect to API (ECONNRESET)\",\"error\":\"unknown\"}'\n")
restore := overrideClaudeCLIForTest(t, fakeClaude)
defer restore()
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
APIKey: "sk-test",
Model: "qwen3.5-plus",
})
if err != nil {
t.Fatalf("unexpected provider error: %v", err)
}
var chunks []ai.StreamChunk
err = provider.ChatStream(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
}, func(chunk ai.StreamChunk) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("expected stream provider to report error via callback, got %v", err)
}
if len(chunks) != 1 {
t.Fatalf("expected a single terminal error chunk, got %#v", chunks)
}
if chunks[0].Content != "" {
t.Fatalf("expected result error event to avoid content output, got %#v", chunks[0])
}
if !chunks[0].Done || !strings.Contains(chunks[0].Error, "Unable to connect to API") {
t.Fatalf("expected upstream API error chunk, got %#v", chunks[0])
}
}
func TestClaudeCLIProvider_ChatStreamReportsApiRetryAuthenticationFailure(t *testing.T) {
fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"system\",\"subtype\":\"api_retry\",\"attempt\":1,\"max_retries\":10,\"retry_delay_ms\":536.11,\"error_status\":401,\"error\":\"authentication_failed\",\"session_id\":\"retry-1\"}'\nexec sleep 5\n")
restore := overrideClaudeCLIForTest(t, fakeClaude)
defer restore()
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
APIKey: "sk-test",
Model: "qwen3.5-plus",
})
if err != nil {
t.Fatalf("unexpected provider error: %v", err)
}
var chunks []ai.StreamChunk
err = provider.ChatStream(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
}, func(chunk ai.StreamChunk) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("expected stream provider to report authentication error via callback, got %v", err)
}
if len(chunks) != 1 {
t.Fatalf("expected a single terminal error chunk, got %#v", chunks)
}
if !chunks[0].Done {
t.Fatalf("expected terminal error chunk, got %#v", chunks[0])
}
if strings.Contains(chunks[0].Error, "未收到模型响应") {
t.Fatalf("expected auth failure instead of startup timeout, got %#v", chunks[0])
}
if !strings.Contains(chunks[0].Error, "401") || !strings.Contains(chunks[0].Error, "authentication_failed") {
t.Fatalf("expected auth retry error details, got %#v", chunks[0])
}
}
func writeFakeClaudeScript(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
if runtime.GOOS == "windows" {
scriptPath := filepath.Join(dir, "claude.sh")
if err := os.WriteFile(scriptPath, []byte(content), 0o755); err != nil {
t.Fatalf("failed to write fake claude shell script: %v", err)
}
return scriptPath
}
path := filepath.Join(dir, "claude")
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
t.Fatalf("failed to write fake claude script: %v", err)
}
return path
}
func overrideClaudeCLIForTest(t *testing.T, fakeClaudePath string) func() {
t.Helper()
originalLookPath := claudeLookPath
originalCommandContext := claudeCommandContext
claudeLookPath = func(name string) (string, error) {
if name == "claude" {
return fakeClaudePath, nil
}
return originalLookPath(name)
}
claudeCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
if name == "claude" {
if runtime.GOOS == "windows" {
bashPath := resolveGitBashForFakeClaudeTest(t, originalLookPath)
bashScriptPath := gitBashTestPath(fakeClaudePath)
return exec.CommandContext(ctx, bashPath, append([]string{bashScriptPath}, args...)...)
}
return exec.CommandContext(ctx, fakeClaudePath, args...)
}
return originalCommandContext(ctx, name, args...)
}
originalPath := os.Getenv("PATH")
if err := os.Setenv("PATH", filepath.Dir(fakeClaudePath)+string(os.PathListSeparator)+originalPath); err != nil {
t.Fatalf("failed to override PATH: %v", err)
}
return func() {
claudeLookPath = originalLookPath
claudeCommandContext = originalCommandContext
_ = os.Setenv("PATH", originalPath)
}
}
func resolveGitBashForFakeClaudeTest(t *testing.T, lookPath func(string) (string, error)) string {
t.Helper()
if configured := strings.TrimSpace(os.Getenv("CLAUDE_CODE_GIT_BASH_PATH")); configured != "" && fileExists(configured) {
return configured
}
gitPath, err := lookPath("git.exe")
if err == nil {
gitDir := parentWindowsPath(gitPath)
for _, candidate := range []string{
joinWindowsPath(parentWindowsPath(gitDir), "bin", "bash.exe"),
joinWindowsPath(gitDir, "bash.exe"),
} {
if candidate != "" && fileExists(candidate) {
return candidate
}
}
}
t.Fatalf("failed to resolve Git for Windows bash.exe for fake claude command")
return ""
}
func gitBashTestPath(path string) string {
slashed := strings.ReplaceAll(path, `\`, `/`)
volume := filepath.VolumeName(slashed)
if len(volume) == 2 && volume[1] == ':' {
drive := strings.ToLower(volume[:1])
rest := strings.TrimPrefix(slashed[len(volume):], "/")
return "/" + drive + "/" + rest
}
return slashed
}