mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-18 11:22:08 +08:00
580 lines
19 KiB
Go
580 lines
19 KiB
Go
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
|
||
}
|