mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 08:43:34 +08:00
- 新增基于本机官方 CLI 登录状态的 Codex 与 Claude 订阅 Provider - 隔离 API Key、工具与项目配置,完善登录校验和错误处理 - 补充设置界面、国际化及前后端回归测试 Fixes #651
1048 lines
34 KiB
Go
1048 lines
34 KiB
Go
package provider
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"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 TestBuildClaudeCLIEnv_LocalAuthRemovesAPIOverrides(t *testing.T) {
|
||
env, err := buildClaudeCLIEnv(ai.ProviderConfig{
|
||
AuthMode: "local-cli",
|
||
BaseURL: "https://proxy.example",
|
||
APIKey: "should-not-be-used",
|
||
}, []string{
|
||
"PATH=/usr/bin",
|
||
"ANTHROPIC_BASE_URL=https://environment-proxy.example",
|
||
"ANTHROPIC_AUTH_TOKEN=environment-token",
|
||
"ANTHROPIC_API_KEY=environment-key",
|
||
"ANTHROPIC_FOUNDRY_API_KEY=foundry-key",
|
||
"AWS_ACCESS_KEY_ID=aws-access-key",
|
||
"CLAUDE_API_KEY=claude-api-key",
|
||
"CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR=9",
|
||
"CLAUDE_CODE_USE_BEDROCK=1",
|
||
"CLAUDE_CODE_USE_VERTEX=1",
|
||
"CLAUDE_CODE_USE_FOUNDRY=1",
|
||
"CLAUDE_CODE_USE_MANTLE=1",
|
||
"CLAUDE_CODE_USE_ANTHROPIC_AWS=1",
|
||
"GOOGLE_APPLICATION_CREDENTIALS=/tmp/google-credentials.json",
|
||
"CLAUDE_CONFIG_DIR=/tmp/claude-config",
|
||
"CLAUDE_CODE_OAUTH_TOKEN=oauth-token",
|
||
}, "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)
|
||
}
|
||
for _, key := range []string{
|
||
"ANTHROPIC_BASE_URL",
|
||
"ANTHROPIC_AUTH_TOKEN",
|
||
"ANTHROPIC_API_KEY",
|
||
"ANTHROPIC_FOUNDRY_API_KEY",
|
||
"AWS_ACCESS_KEY_ID",
|
||
"CLAUDE_API_KEY",
|
||
"CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR",
|
||
"CLAUDE_CODE_USE_BEDROCK",
|
||
"CLAUDE_CODE_USE_VERTEX",
|
||
"CLAUDE_CODE_USE_FOUNDRY",
|
||
"CLAUDE_CODE_USE_MANTLE",
|
||
"CLAUDE_CODE_USE_ANTHROPIC_AWS",
|
||
"GOOGLE_APPLICATION_CREDENTIALS",
|
||
} {
|
||
if got := envValue(env, key); got != "" {
|
||
t.Fatalf("expected %s to be removed in local auth mode, got %q", key, got)
|
||
}
|
||
}
|
||
if got := envValue(env, "CLAUDE_CONFIG_DIR"); got != "/tmp/claude-config" {
|
||
t.Fatalf("expected Claude config directory to be preserved for OAuth, got %q", got)
|
||
}
|
||
if got := envValue(env, "CLAUDE_CODE_OAUTH_TOKEN"); got != "oauth-token" {
|
||
t.Fatalf("expected OAuth token to be preserved, got %q", got)
|
||
}
|
||
for _, key := range claudeCLILocalAuthIsolationEnvKeys {
|
||
if got := envValue(env, key); got != "1" {
|
||
t.Fatalf("expected %s=1 in local auth mode, got %q", key, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestBuildClaudeCLIArgs_LocalAuthKeepsPromptOutOfArgvAndDisablesTools(t *testing.T) {
|
||
args := buildClaudeCLIArgs(ai.ProviderConfig{AuthMode: "local-cli"}, "private prompt", true)
|
||
if strings.Contains(strings.Join(args, " "), "private prompt") {
|
||
t.Fatalf("expected prompt to stay out of argv, got %#v", args)
|
||
}
|
||
if !hasArgSequence(args, "--tools", "") {
|
||
t.Fatalf("expected tools to be disabled, got %#v", args)
|
||
}
|
||
if !hasArgSequence(args, "--permission-mode", "dontAsk") {
|
||
t.Fatalf("expected non-interactive permission mode, got %#v", args)
|
||
}
|
||
if !hasArg(args, "--print") || hasArg(args, "-p") {
|
||
t.Fatalf("expected stdin-based --print mode without a prompt argument, got %#v", args)
|
||
}
|
||
if !hasArgSequence(args, "--setting-sources", "user") {
|
||
t.Fatalf("expected user settings source so Claude 2.1.132 can load OAuth, got %#v", args)
|
||
}
|
||
if !hasArgSequence(args, "--settings", claudeCLILocalAuthSettings) {
|
||
t.Fatalf("expected inline hook and CLAUDE.md isolation settings, got %#v", args)
|
||
}
|
||
for _, expected := range []string{"--disable-slash-commands", "--no-chrome", "--no-session-persistence"} {
|
||
if !hasArg(args, expected) {
|
||
t.Fatalf("expected %q in args, got %#v", expected, args)
|
||
}
|
||
}
|
||
if !hasArg(args, "--strict-mcp-config") {
|
||
t.Fatalf("expected MCP configuration isolation, got %#v", args)
|
||
}
|
||
if hasArg(args, "--bare") {
|
||
t.Fatalf("--bare disables OAuth and must not be used for subscription auth: %#v", args)
|
||
}
|
||
}
|
||
|
||
func TestClaudeCLILocalAuthSettingsNeutralizeUserEnvironmentOverrides(t *testing.T) {
|
||
var settings struct {
|
||
APIKeyHelper string `json:"apiKeyHelper"`
|
||
Env any `json:"env"`
|
||
}
|
||
if err := json.Unmarshal([]byte(claudeCLILocalAuthSettings), &settings); err != nil {
|
||
t.Fatalf("parse isolation settings: %v", err)
|
||
}
|
||
if settings.APIKeyHelper != "" {
|
||
t.Fatalf("expected user apiKeyHelper to be disabled, got %q", settings.APIKeyHelper)
|
||
}
|
||
if settings.Env != nil {
|
||
t.Fatalf("expected complete user settings env block to be neutralized, got %#v", settings.Env)
|
||
}
|
||
}
|
||
|
||
func TestValidateClaudeCLISubscriptionStatus(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
status claudeCLIAuthStatus
|
||
wantErr bool
|
||
wantDetail string
|
||
}{
|
||
{
|
||
name: "oauth subscription",
|
||
status: claudeCLIAuthStatus{
|
||
LoggedIn: true,
|
||
AuthMethod: "oauth_token",
|
||
APIProvider: "firstParty",
|
||
},
|
||
},
|
||
{
|
||
name: "logged out",
|
||
status: claudeCLIAuthStatus{},
|
||
wantErr: true,
|
||
wantDetail: "claude auth login",
|
||
},
|
||
{
|
||
name: "api key override",
|
||
status: claudeCLIAuthStatus{
|
||
LoggedIn: true,
|
||
AuthMethod: "oauth_token",
|
||
APIProvider: "firstParty",
|
||
APIKeySource: "ANTHROPIC_API_KEY",
|
||
},
|
||
wantErr: true,
|
||
wantDetail: "ANTHROPIC_API_KEY",
|
||
},
|
||
{
|
||
name: "third-party provider",
|
||
status: claudeCLIAuthStatus{
|
||
LoggedIn: true,
|
||
AuthMethod: "oauth_token",
|
||
APIProvider: "bedrock",
|
||
},
|
||
wantErr: true,
|
||
wantDetail: "bedrock",
|
||
},
|
||
{
|
||
name: "non-oauth method",
|
||
status: claudeCLIAuthStatus{
|
||
LoggedIn: true,
|
||
AuthMethod: "api_key",
|
||
APIProvider: "firstParty",
|
||
},
|
||
wantErr: true,
|
||
wantDetail: "api_key",
|
||
},
|
||
{
|
||
name: "misleading oauth substring",
|
||
status: claudeCLIAuthStatus{
|
||
LoggedIn: true,
|
||
AuthMethod: "not_oauth",
|
||
APIProvider: "firstParty",
|
||
},
|
||
wantErr: true,
|
||
wantDetail: "not_oauth",
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
err := validateClaudeCLISubscriptionStatus(tt.status)
|
||
if (err != nil) != tt.wantErr {
|
||
t.Fatalf("validate status error = %v, wantErr %v", err, tt.wantErr)
|
||
}
|
||
if tt.wantDetail != "" && (err == nil || !strings.Contains(err.Error(), tt.wantDetail)) {
|
||
t.Fatalf("expected error containing %q, got %v", tt.wantDetail, err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestCheckClaudeCLILocalAuthUsesStatusWithoutModelRequest(t *testing.T) {
|
||
restore := overrideClaudeCLIWithTestProcess(t, "auth-success")
|
||
defer restore()
|
||
|
||
if err := CheckClaudeCLILocalAuth(context.Background()); err != nil {
|
||
t.Fatalf("expected OAuth status check success, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestCheckClaudeCLILocalAuthReportsLoggedOutStatus(t *testing.T) {
|
||
restore := overrideClaudeCLIWithTestProcess(t, "logged-out")
|
||
defer restore()
|
||
|
||
err := CheckClaudeCLILocalAuth(context.Background())
|
||
if err == nil || !strings.Contains(err.Error(), "claude auth login") {
|
||
t.Fatalf("expected actionable logged-out error, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestClaudeCLILocalAuthRejectsAPIKeyOverrideBeforeChatAndStream(t *testing.T) {
|
||
restore := overrideClaudeCLIWithTestProcess(t, "api-key-override")
|
||
defer restore()
|
||
|
||
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{AuthMode: "local-cli"})
|
||
if err != nil {
|
||
t.Fatalf("create provider: %v", err)
|
||
}
|
||
request := ai.ChatRequest{Messages: []ai.Message{{Role: "user", Content: "ping"}}}
|
||
|
||
if _, err := provider.Chat(context.Background(), request); err == nil || !strings.Contains(err.Error(), "apiKeyHelper") {
|
||
t.Fatalf("expected Chat to reject API key override before model request, got %v", err)
|
||
}
|
||
if err := provider.ChatStream(context.Background(), request, func(ai.StreamChunk) {}); err == nil || !strings.Contains(err.Error(), "apiKeyHelper") {
|
||
t.Fatalf("expected ChatStream to reject API key override before model request, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestCheckClaudeCLILocalAuthReturnsContextCanceled(t *testing.T) {
|
||
restore := overrideClaudeCLIWithTestProcess(t, "sleep")
|
||
defer restore()
|
||
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
time.AfterFunc(50*time.Millisecond, cancel)
|
||
err := CheckClaudeCLILocalAuth(ctx)
|
||
if !errors.Is(err, context.Canceled) {
|
||
t.Fatalf("expected context.Canceled from auth status, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestClaudeCLIChatReturnsContextCanceled(t *testing.T) {
|
||
restore := overrideClaudeCLIWithTestProcess(t, "sleep")
|
||
defer restore()
|
||
|
||
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
|
||
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
|
||
APIKey: "qwen-key",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create provider: %v", err)
|
||
}
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
time.AfterFunc(50*time.Millisecond, cancel)
|
||
_, err = provider.Chat(ctx, ai.ChatRequest{Messages: []ai.Message{{Role: "user", Content: "ping"}}})
|
||
if !errors.Is(err, context.Canceled) {
|
||
t.Fatalf("expected context.Canceled from Chat, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestClaudeCLIChatStreamReturnsContextCanceledWithoutErrorChunk(t *testing.T) {
|
||
restore := overrideClaudeCLIWithTestProcess(t, "sleep")
|
||
defer restore()
|
||
|
||
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
|
||
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
|
||
APIKey: "qwen-key",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create provider: %v", err)
|
||
}
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
time.AfterFunc(50*time.Millisecond, cancel)
|
||
var chunks []ai.StreamChunk
|
||
err = provider.ChatStream(ctx, ai.ChatRequest{Messages: []ai.Message{{Role: "user", Content: "ping"}}}, func(chunk ai.StreamChunk) {
|
||
chunks = append(chunks, chunk)
|
||
})
|
||
if !errors.Is(err, context.Canceled) {
|
||
t.Fatalf("expected context.Canceled from ChatStream, got %v", err)
|
||
}
|
||
if len(chunks) != 0 {
|
||
t.Fatalf("cancellation must not emit a user-facing error chunk, got %#v", chunks)
|
||
}
|
||
}
|
||
|
||
func TestBuildClaudeCLIArgs_ProxyModePreservesExistingPromptArgument(t *testing.T) {
|
||
args := buildClaudeCLIArgs(ai.ProviderConfig{}, "proxy prompt", false)
|
||
if !hasArgSequence(args, "-p", "proxy prompt") {
|
||
t.Fatalf("expected proxy mode to preserve argv prompt for compatibility, got %#v", args)
|
||
}
|
||
}
|
||
|
||
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 TestResolveClaudeCLICommandUsesNPMNativeBinaryOnWindows(t *testing.T) {
|
||
cmdPath := filepath.Join("npm", "claude.cmd")
|
||
nativePath := filepath.Join("npm", "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe")
|
||
standalonePath := filepath.Join("standalone", "claude.exe")
|
||
|
||
command, err := resolveClaudeCLICommand("windows", "amd64", func(name string) (string, error) {
|
||
switch name {
|
||
case "claude.cmd":
|
||
return cmdPath, nil
|
||
case "claude.exe":
|
||
return standalonePath, nil
|
||
default:
|
||
return "", errors.New("not found")
|
||
}
|
||
}, func(path string) bool {
|
||
return path == cmdPath || path == nativePath || path == standalonePath
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("resolve Claude CLI command: %v", err)
|
||
}
|
||
if command.Path != nativePath {
|
||
t.Fatalf("expected npm native binary %q, got %#v", nativePath, command)
|
||
}
|
||
}
|
||
|
||
func TestResolveClaudeCLICommandFallsBackToStandaloneExeOnWindows(t *testing.T) {
|
||
cmdPath := filepath.Join("npm", "claude.cmd")
|
||
standalonePath := filepath.Join("standalone", "claude.exe")
|
||
|
||
command, err := resolveClaudeCLICommand("windows", "amd64", func(name string) (string, error) {
|
||
switch name {
|
||
case "claude.cmd":
|
||
return cmdPath, nil
|
||
case "claude.exe":
|
||
return standalonePath, nil
|
||
default:
|
||
return "", errors.New("not found")
|
||
}
|
||
}, func(path string) bool {
|
||
return path == cmdPath || path == standalonePath
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("resolve Claude CLI command: %v", err)
|
||
}
|
||
if command.Path != standalonePath {
|
||
t.Fatalf("expected standalone binary %q, got %#v", standalonePath, command)
|
||
}
|
||
}
|
||
|
||
func TestResolveClaudeCLICommandDoesNotReturnCmdWhenNativeBinaryIsMissing(t *testing.T) {
|
||
cmdPath := filepath.Join("npm", "claude.cmd")
|
||
_, err := resolveClaudeCLICommand("windows", "amd64", func(name string) (string, error) {
|
||
if name == "claude.cmd" || name == "claude" {
|
||
return cmdPath, nil
|
||
}
|
||
return "", errors.New("not found")
|
||
}, func(path string) bool {
|
||
return path == cmdPath
|
||
})
|
||
if err == nil {
|
||
t.Fatal("expected missing native executable error instead of returning the cmd launcher")
|
||
}
|
||
if err.Error() != claudeCLIInstallError().Error() {
|
||
t.Fatalf("expected actionable install error, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestClaudeNPMNativeBinaryCandidatesSupportsWindowsArm64(t *testing.T) {
|
||
candidates := claudeNPMNativeBinaryCandidates(filepath.Join("npm", "claude.cmd"), "windows", "arm64")
|
||
wantPart := filepath.Join("@anthropic-ai", "claude-code-win32-arm64", "claude.exe")
|
||
found := false
|
||
for _, candidate := range candidates {
|
||
if strings.Contains(candidate, wantPart) {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("expected Windows arm64 native package candidate, got %#v", candidates)
|
||
}
|
||
}
|
||
|
||
func TestClaudeNPMPlatformTargetSupportsOfficialPackages(t *testing.T) {
|
||
tests := []struct {
|
||
goos string
|
||
goarch string
|
||
wantPkg string
|
||
wantBinary string
|
||
}{
|
||
{"windows", "amd64", "claude-code-win32-x64", "claude.exe"},
|
||
{"windows", "arm64", "claude-code-win32-arm64", "claude.exe"},
|
||
{"darwin", "amd64", "claude-code-darwin-x64", "claude"},
|
||
{"darwin", "arm64", "claude-code-darwin-arm64", "claude"},
|
||
{"linux", "amd64", "claude-code-linux-x64", "claude"},
|
||
{"linux", "arm64", "claude-code-linux-arm64", "claude"},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.goos+"-"+tt.goarch, func(t *testing.T) {
|
||
pkg, binary, ok := claudeNPMPlatformTarget(tt.goos, tt.goarch)
|
||
if !ok || pkg != tt.wantPkg || binary != tt.wantBinary {
|
||
t.Fatalf("unexpected target: package=%q binary=%q ok=%v", pkg, binary, ok)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
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" || name == "claude.cmd" || name == "claude.exe" {
|
||
return fakeClaudePath, nil
|
||
}
|
||
return originalLookPath(name)
|
||
}
|
||
claudeCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
|
||
if name == "claude" || name == fakeClaudePath {
|
||
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 overrideClaudeCLIWithTestProcess(t *testing.T, mode string) func() {
|
||
t.Helper()
|
||
testExecutable, err := os.Executable()
|
||
if err != nil {
|
||
t.Fatalf("resolve test executable: %v", err)
|
||
}
|
||
|
||
originalLookPath := claudeLookPath
|
||
originalCommandContext := claudeCommandContext
|
||
claudeLookPath = func(name string) (string, error) {
|
||
if name == "claude" || name == "claude.cmd" || name == "claude.exe" {
|
||
return testExecutable, nil
|
||
}
|
||
return originalLookPath(name)
|
||
}
|
||
claudeCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
|
||
isAuthStatus := hasArg(args, "auth") && hasArg(args, "status") && hasArg(args, "--json")
|
||
helperMode := mode
|
||
switch mode {
|
||
case "auth-success", "logged-out":
|
||
if !isAuthStatus {
|
||
helperMode = "unexpected-model-request"
|
||
}
|
||
case "api-key-override":
|
||
if !isAuthStatus {
|
||
helperMode = "model-success"
|
||
}
|
||
}
|
||
helperArgs := []string{"-test.run=^TestClaudeCLIHelperProcess$"}
|
||
cmd := exec.CommandContext(ctx, testExecutable, helperArgs...)
|
||
cmd.Env = append(os.Environ(),
|
||
"GO_WANT_CLAUDE_CLI_HELPER_PROCESS=1",
|
||
"GONAVI_CLAUDE_CLI_HELPER_MODE="+helperMode,
|
||
)
|
||
return cmd
|
||
}
|
||
|
||
return func() {
|
||
claudeLookPath = originalLookPath
|
||
claudeCommandContext = originalCommandContext
|
||
}
|
||
}
|
||
|
||
func TestClaudeCLIHelperProcess(t *testing.T) {
|
||
if os.Getenv("GO_WANT_CLAUDE_CLI_HELPER_PROCESS") != "1" {
|
||
return
|
||
}
|
||
|
||
switch os.Getenv("GONAVI_CLAUDE_CLI_HELPER_MODE") {
|
||
case "auth-success":
|
||
_, _ = os.Stdout.WriteString(`{"loggedIn":true,"authMethod":"oauth_token","apiProvider":"firstParty"}`)
|
||
os.Exit(0)
|
||
case "logged-out":
|
||
_, _ = os.Stdout.WriteString(`{"loggedIn":false,"authMethod":"none","apiProvider":"firstParty"}`)
|
||
os.Exit(1)
|
||
case "api-key-override":
|
||
_, _ = os.Stdout.WriteString(`{"loggedIn":true,"authMethod":"oauth_token","apiProvider":"firstParty","apiKeySource":"apiKeyHelper"}`)
|
||
os.Exit(0)
|
||
case "model-success":
|
||
_, _ = os.Stdout.WriteString(`{"type":"result","subtype":"success","is_error":false,"result":"model request should not run"}`)
|
||
os.Exit(0)
|
||
case "sleep":
|
||
time.Sleep(5 * time.Second)
|
||
os.Exit(0)
|
||
case "unexpected-model-request":
|
||
_, _ = os.Stderr.WriteString("unexpected model request")
|
||
os.Exit(9)
|
||
default:
|
||
_, _ = os.Stderr.WriteString("unknown Claude CLI helper mode")
|
||
os.Exit(10)
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|