Files
MyGoNavi/internal/ai/provider/codex_cli_test.go
Syngnat 6cd2835066 feat(ai): 支持 Codex 与 Claude 订阅登录
- 新增基于本机官方 CLI 登录状态的 Codex 与 Claude 订阅 Provider
- 隔离 API Key、工具与项目配置,完善登录校验和错误处理
- 补充设置界面、国际化及前后端回归测试

Fixes #651
2026-07-18 11:57:25 +08:00

428 lines
15 KiB
Go

package provider
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"GoNavi-Wails/internal/ai"
)
func TestBuildCodexCLIArgsUsesIsolatedReadOnlyExecutionAndStdin(t *testing.T) {
args := buildCodexCLIArgs(ai.ProviderConfig{Model: "gpt-5-codex"})
for _, expected := range []string{
"exec",
"--ignore-user-config",
"--ignore-rules",
"--ephemeral",
"--skip-git-repo-check",
"--json",
"-",
} {
if !hasArg(args, expected) {
t.Fatalf("expected %q in Codex args, got %#v", expected, args)
}
}
if !hasArgSequence(args, "--sandbox", "read-only") {
t.Fatalf("expected read-only sandbox, got %#v", args)
}
if !hasArgSequence(args, "--disable", "shell_tool") {
t.Fatalf("expected shell tool to be disabled, got %#v", args)
}
for _, feature := range codexCLIDisabledFeatures {
if !hasArgSequence(args, "--disable", feature) {
t.Fatalf("expected feature %q to be disabled, got %#v", feature, args)
}
}
for _, criticalFeature := range []string{
"shell_tool", "hooks", "memories", "multi_agent", "apps", "enable_mcp_apps",
"plugins", "plugin_hooks", "in_app_browser", "browser_use", "browser_use_external",
"computer_use", "image_generation", "remote_control",
} {
if !hasArgSequence(args, "--disable", criticalFeature) {
t.Fatalf("expected critical capability %q to be disabled, got %#v", criticalFeature, args)
}
}
if !hasArgSequence(args, "-c", "mcp_servers={}") {
t.Fatalf("expected MCP servers to be cleared, got %#v", args)
}
for _, configOverride := range []string{"skills.include_instructions=false", "skills.bundled.enabled=false"} {
if !hasArgSequence(args, "-c", configOverride) {
t.Fatalf("expected skill isolation override %q, got %#v", configOverride, args)
}
}
if !hasArgSequence(args, "-m", "gpt-5-codex") {
t.Fatalf("expected explicit model, got %#v", args)
}
if strings.Contains(strings.Join(args, " "), "secret prompt") {
t.Fatalf("prompt must not be passed in argv: %#v", args)
}
}
func TestBuildCodexCLIArgsLeavesModelToSubscriptionDefault(t *testing.T) {
args := buildCodexCLIArgs(ai.ProviderConfig{})
if hasArg(args, "-m") {
t.Fatalf("expected no model override, got %#v", args)
}
}
func TestNewCodexCLIProviderRejectsAPIKeyAuthMode(t *testing.T) {
if _, err := NewCodexCLIProvider(ai.ProviderConfig{AuthMode: "api-key"}); err == nil || !strings.Contains(err.Error(), "local-cli") {
t.Fatalf("expected an actionable local-cli auth error, got %v", err)
}
}
func TestBuildCodexCLIEnvRemovesAPIKeyOverrides(t *testing.T) {
env := buildCodexCLIEnv([]string{
"PATH=/usr/bin",
"CODEX_HOME=/tmp/codex-home",
"CODEX_API_KEY=codex-key",
"OPENAI_API_KEY=openai-key",
"OPENAI_BASE_URL=https://example.invalid",
})
for _, key := range []string{"CODEX_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL"} {
if got := envValue(env, key); got != "" {
t.Fatalf("expected %s to be removed, got %q", key, got)
}
}
if got := envValue(env, "CODEX_HOME"); got != "/tmp/codex-home" {
t.Fatalf("expected login home to be preserved, got %q", got)
}
}
func TestConsumeCodexCLIEventKeepsFinalMessageAndIgnoresRetryErrorAfterSuccess(t *testing.T) {
result := codexCLIResult{}
consumeCodexCLIEvent(&result, codexCLIEvent{Type: "error", Message: "temporary network error"})
consumeCodexCLIEvent(&result, codexCLIEvent{Type: "item.completed", Item: codexCLIItem{Type: "agent_message", Text: "draft"}})
consumeCodexCLIEvent(&result, codexCLIEvent{Type: "item.completed", Item: codexCLIItem{Type: "agent_message", Text: "final answer"}})
consumeCodexCLIEvent(&result, codexCLIEvent{Type: "item.completed", Item: codexCLIItem{Type: "reasoning", Text: "summary"}})
consumeCodexCLIEvent(&result, codexCLIEvent{
Type: "turn.completed",
Usage: codexCLIUsage{InputTokens: 10, OutputTokens: 4, ReasoningOutputTokens: 2},
})
if !result.Completed || result.Content != "final answer" || result.Thinking != "summary" {
t.Fatalf("unexpected parsed result: %#v", result)
}
if result.TerminalError != "" {
t.Fatalf("retryable top-level error must not override success: %#v", result)
}
if result.Usage.CompletionTokens != 4 || result.Usage.TotalTokens != 14 {
t.Fatalf("unexpected usage: %#v", result.Usage)
}
}
func TestCodexCLIProviderChatReadsPromptFromStdinAndParsesJSONL(t *testing.T) {
restore := overrideCodexCLIForTest(t, "success")
defer restore()
provider, err := NewCodexCLIProvider(ai.ProviderConfig{AuthMode: "local-cli"})
if err != nil {
t.Fatalf("create provider: %v", err)
}
resp, err := provider.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "hello from stdin"}},
})
if err != nil {
t.Fatalf("chat failed: %v", err)
}
if resp.Content != "hello from stdin" {
t.Fatalf("expected helper to echo stdin, got %#v", resp)
}
if resp.ReasoningContent != "checked safely" {
t.Fatalf("expected reasoning summary, got %#v", resp)
}
if resp.TokensUsed.CompletionTokens != 3 || resp.TokensUsed.TotalTokens != 8 {
t.Fatalf("unexpected usage: %#v", resp.TokensUsed)
}
}
func TestCodexCLIProviderChatRejectsNonSubscriptionAuthBeforeModelRequest(t *testing.T) {
originalAuthCheck := codexCLIChatGPTAuthCheck
originalCommandContext := codexCommandContext
defer func() {
codexCLIChatGPTAuthCheck = originalAuthCheck
codexCommandContext = originalCommandContext
}()
codexCLIChatGPTAuthCheck = func(context.Context) error {
return errors.New("Codex CLI is not logged in with a ChatGPT subscription; API key login detected")
}
modelStarted := false
codexCommandContext = func(ctx context.Context, path string, args ...string) *exec.Cmd {
modelStarted = true
return originalCommandContext(ctx, path, args...)
}
provider, _ := NewCodexCLIProvider(ai.ProviderConfig{AuthMode: "local-cli"})
_, err := provider.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "must not be sent"}},
})
if err == nil || !strings.Contains(err.Error(), "ChatGPT subscription") {
t.Fatalf("expected subscription auth error, got %v", err)
}
if modelStarted {
t.Fatal("model command must not start when subscription auth validation fails")
}
}
func TestCheckCodexCLIAuthUsesLoginStatusWithoutModelRequest(t *testing.T) {
restore := overrideCodexCLIForTest(t, "login-chatgpt")
defer restore()
helperCommandContext := codexCommandContext
var gotArgs []string
codexCommandContext = func(ctx context.Context, path string, args ...string) *exec.Cmd {
gotArgs = append([]string(nil), args...)
return helperCommandContext(ctx, path, args...)
}
if err := CheckCodexCLIAuth(context.Background()); err != nil {
t.Fatalf("check Codex login: %v", err)
}
expectedArgs := []string{"login", "status", "-c", codexCLILoginConfigOverride}
if strings.Join(gotArgs, "\x00") != strings.Join(expectedArgs, "\x00") {
t.Fatalf("expected login status only, got %#v", gotArgs)
}
}
func TestCheckCodexCLIAuthRejectsAPIKeyLogin(t *testing.T) {
restore := overrideCodexCLIForTest(t, "login-api-key")
defer restore()
err := CheckCodexCLIAuth(context.Background())
if err == nil || !strings.Contains(err.Error(), "ChatGPT subscription") || !strings.Contains(err.Error(), "API key") {
t.Fatalf("expected API key login to be rejected with an actionable error, got %v", err)
}
}
func TestCheckCodexCLIAuthReportsMissingLogin(t *testing.T) {
restore := overrideCodexCLIForTest(t, "login-failed")
defer restore()
err := CheckCodexCLIAuth(context.Background())
if err == nil || !strings.Contains(err.Error(), "Not logged in") || !strings.Contains(err.Error(), "codex login") {
t.Fatalf("expected actionable login error, got %v", err)
}
}
func TestCodexCLIProviderChatStreamEmitsOneFinalAnswer(t *testing.T) {
restore := overrideCodexCLIForTest(t, "success")
defer restore()
provider, _ := NewCodexCLIProvider(ai.ProviderConfig{AuthMode: "local-cli"})
var chunks []ai.StreamChunk
err := provider.ChatStream(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "stream answer"}},
}, func(chunk ai.StreamChunk) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("stream failed: %v", err)
}
if len(chunks) != 3 {
t.Fatalf("expected thinking, content, done chunks, got %#v", chunks)
}
if chunks[0].Thinking != "checked safely" || chunks[1].Content != "stream answer" || !chunks[2].Done {
t.Fatalf("unexpected stream chunks: %#v", chunks)
}
}
func TestCodexCLIProviderChatStreamReportsTurnFailure(t *testing.T) {
restore := overrideCodexCLIForTest(t, "failed")
defer restore()
provider, _ := NewCodexCLIProvider(ai.ProviderConfig{AuthMode: "local-cli"})
var chunks []ai.StreamChunk
err := provider.ChatStream(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "fail"}},
}, func(chunk ai.StreamChunk) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("expected callback error convention, got %v", err)
}
if len(chunks) != 1 || !chunks[0].Done || !strings.Contains(chunks[0].Error, "not logged in") {
t.Fatalf("expected terminal authentication error chunk, got %#v", chunks)
}
}
func TestCodexCLIProviderHonorsCancellation(t *testing.T) {
restore := overrideCodexCLIForTest(t, "sleep")
defer restore()
provider, _ := NewCodexCLIProvider(ai.ProviderConfig{AuthMode: "local-cli"})
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := provider.Chat(ctx, ai.ChatRequest{Messages: []ai.Message{{Role: "user", Content: "cancel"}}})
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context cancellation, got %v", err)
}
}
func TestCodexCLIProviderStopsRunningProcessWhenDeadlineExpires(t *testing.T) {
restore := overrideCodexCLIForTest(t, "sleep")
defer restore()
originalTimeout := codexCLIRequestTimeout
codexCLIRequestTimeout = 200 * time.Millisecond
defer func() { codexCLIRequestTimeout = originalTimeout }()
provider, _ := NewCodexCLIProvider(ai.ProviderConfig{AuthMode: "local-cli"})
started := time.Now()
_, err := provider.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "timeout"}},
})
if err == nil || !strings.Contains(err.Error(), "timed out") {
t.Fatalf("expected timeout error, got %v", err)
}
if elapsed := time.Since(started); elapsed > 2*time.Second {
t.Fatalf("expected the native CLI process to stop promptly, took %s", elapsed)
}
}
func TestResolveCodexCLICommandUsesNPMVendorBinaryOnWindows(t *testing.T) {
cmdPath := filepath.Join("npm", "codex.cmd")
nativePath := filepath.Join(
"npm", "node_modules", "@openai", "codex", "node_modules", "@openai", "codex-win32-x64",
"vendor", "x86_64-pc-windows-msvc", "bin", "codex.exe",
)
command, err := resolveCodexCLICommand("windows", "amd64", func(name string) (string, error) {
switch name {
case "codex.cmd":
return cmdPath, nil
case "codex.exe":
return filepath.Join("windows-apps", "codex.exe"), nil
default:
return "", errors.New("not found")
}
}, func(path string) bool {
return path == cmdPath || path == nativePath || path == filepath.Join("windows-apps", "codex.exe")
})
if err != nil {
t.Fatalf("resolve command: %v", err)
}
if command.Path != nativePath || len(command.PrefixArgs) != 0 {
t.Fatalf("unexpected command: %#v", command)
}
}
func TestCodexNPMNativeBinaryCandidatesSupportsArm64(t *testing.T) {
candidates := codexNPMNativeBinaryCandidates(filepath.Join("npm", "codex.cmd"), "windows", "arm64")
if len(candidates) == 0 || !strings.Contains(candidates[0], "codex-win32-arm64") || !strings.Contains(candidates[0], "aarch64-pc-windows-msvc") {
t.Fatalf("unexpected arm64 candidates: %#v", candidates)
}
}
func TestCodexNPMPlatformTargetSupportsOfficialPackages(t *testing.T) {
tests := []struct {
goos string
goarch string
wantPackage string
wantTriple string
wantBinary string
}{
{"linux", "amd64", "codex-linux-x64", "x86_64-unknown-linux-musl", "codex"},
{"linux", "arm64", "codex-linux-arm64", "aarch64-unknown-linux-musl", "codex"},
{"darwin", "amd64", "codex-darwin-x64", "x86_64-apple-darwin", "codex"},
{"darwin", "arm64", "codex-darwin-arm64", "aarch64-apple-darwin", "codex"},
{"windows", "amd64", "codex-win32-x64", "x86_64-pc-windows-msvc", "codex.exe"},
{"windows", "arm64", "codex-win32-arm64", "aarch64-pc-windows-msvc", "codex.exe"},
}
for _, test := range tests {
t.Run(test.goos+"-"+test.goarch, func(t *testing.T) {
pkg, triple, binary, ok := codexNPMPlatformTarget(test.goos, test.goarch)
if !ok || pkg != test.wantPackage || triple != test.wantTriple || binary != test.wantBinary {
t.Fatalf("unexpected target: package=%q triple=%q binary=%q ok=%v", pkg, triple, binary, ok)
}
})
}
}
func TestCodexCLIHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_CODEX_HELPER") != "1" {
return
}
mode := os.Getenv("GO_CODEX_HELPER_MODE")
if mode == "login-chatgpt" {
_, _ = fmt.Fprintln(os.Stdout, "Logged in using ChatGPT")
return
}
if mode == "login-api-key" {
_, _ = fmt.Fprintln(os.Stdout, "Logged in using an API key")
return
}
if mode == "login-failed" {
_, _ = fmt.Fprintln(os.Stderr, "Not logged in")
os.Exit(1)
}
if mode == "sleep" {
time.Sleep(5 * time.Second)
return
}
prompt, _ := io.ReadAll(os.Stdin)
encoder := json.NewEncoder(os.Stdout)
if mode == "failed" {
_ = encoder.Encode(map[string]any{
"type": "turn.failed",
"error": map[string]string{"message": "not logged in; run codex login"},
})
return
}
_ = encoder.Encode(map[string]any{
"type": "item.completed",
"item": map[string]string{"type": "reasoning", "text": "checked safely"},
})
_ = encoder.Encode(map[string]any{
"type": "item.completed",
"item": map[string]string{"type": "agent_message", "text": strings.TrimSpace(string(prompt))},
})
_ = encoder.Encode(map[string]any{
"type": "turn.completed",
"usage": map[string]int{
"input_tokens": 5,
"cached_input_tokens": 1,
"output_tokens": 3,
"reasoning_output_tokens": 1,
},
})
}
func overrideCodexCLIForTest(t *testing.T, mode string) func() {
t.Helper()
originalLookPath := codexLookPath
originalCommandContext := codexCommandContext
originalAuthCheck := codexCLIChatGPTAuthCheck
executable, err := os.Executable()
if err != nil {
t.Fatalf("resolve test executable: %v", err)
}
codexLookPath = func(name string) (string, error) {
if name == "codex" || name == "codex.exe" {
return executable, nil
}
return "", fmt.Errorf("unexpected command lookup: %s", name)
}
codexCommandContext = func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
cmd := exec.CommandContext(ctx, executable, "-test.run=TestCodexCLIHelperProcess", "--")
cmd.Env = append(os.Environ(), "GO_WANT_CODEX_HELPER=1", "GO_CODEX_HELPER_MODE="+mode)
return cmd
}
codexCLIChatGPTAuthCheck = func(context.Context) error { return nil }
return func() {
codexLookPath = originalLookPath
codexCommandContext = originalCommandContext
codexCLIChatGPTAuthCheck = originalAuthCheck
}
}