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

864 lines
33 KiB
Go

package aiservice
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
"testing"
"GoNavi-Wails/shared/i18n"
)
func TestResolveLocalMCPCommandUsesMainBinaryWithArgument(t *testing.T) {
command, args, err := resolveLocalMCPCommand(`C:\Program Files\GoNavi\GoNavi.exe`)
if err != nil {
t.Fatalf("resolveLocalMCPCommand returned error: %v", err)
}
if command != `C:\Program Files\GoNavi\GoNavi.exe` {
t.Fatalf("expected command to keep main binary path, got %q", command)
}
if !reflect.DeepEqual(args, []string{"mcp-server"}) {
t.Fatalf("expected main binary args %#v, got %#v", []string{"mcp-server"}, args)
}
}
func TestResolveLocalMCPCommandKeepsDedicatedServerBinary(t *testing.T) {
command, args, err := resolveLocalMCPCommand(`D:\Work\CodeRepos\GoNavi\bin\gonavi-mcp-server.exe`)
if err != nil {
t.Fatalf("resolveLocalMCPCommand returned error: %v", err)
}
if command != `D:\Work\CodeRepos\GoNavi\bin\gonavi-mcp-server.exe` {
t.Fatalf("expected dedicated server path to be reused, got %q", command)
}
if len(args) != 0 {
t.Fatalf("expected dedicated server args to be empty, got %#v", args)
}
}
func TestReadClaudeCodeMCPServerConfigReadsExistingInstall(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, ".claude.json")
initial := map[string]any{
"mcpServers": map[string]any{
gonaviMCPServerID: map[string]any{
"type": "stdio",
"command": `C:\Program Files\GoNavi\GoNavi.exe`,
"args": []string{"mcp-server"},
},
},
}
data, err := json.MarshalIndent(initial, "", " ")
if err != nil {
t.Fatalf("MarshalIndent returned error: %v", err)
}
if err := os.WriteFile(configPath, append(data, '\n'), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
cfg, found, err := readClaudeCodeMCPServerConfig(configPath, gonaviMCPServerID)
if err != nil {
t.Fatalf("readClaudeCodeMCPServerConfig returned error: %v", err)
}
if !found {
t.Fatal("expected gonavi install to be detected")
}
if cfg.Command != `C:\Program Files\GoNavi\GoNavi.exe` {
t.Fatalf("unexpected command: %q", cfg.Command)
}
if !reflect.DeepEqual(cfg.Args, []string{"mcp-server"}) {
t.Fatalf("unexpected args: %#v", cfg.Args)
}
}
func TestUpsertClaudeCodeMCPServerConfigCreatesAndMergesUserConfig(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, ".claude.json")
initial := map[string]any{
"theme": "dark-daltonized",
"mcpServers": map[string]any{
"memory": map[string]any{
"type": "stdio",
"command": "cmd",
},
},
}
data, err := json.MarshalIndent(initial, "", " ")
if err != nil {
t.Fatalf("MarshalIndent returned error: %v", err)
}
if err := os.WriteFile(configPath, append(data, '\n'), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
err = upsertClaudeCodeMCPServerConfig(configPath, gonaviMCPServerID, claudeCodeMCPServerConfig{
Type: "stdio",
Command: `C:\Program Files\GoNavi\GoNavi.exe`,
Args: []string{"mcp-server"},
Env: map[string]string{},
})
if err != nil {
t.Fatalf("upsertClaudeCodeMCPServerConfig returned error: %v", err)
}
updated, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
var root map[string]any
if err := json.Unmarshal(updated, &root); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
}
if got := strings.TrimSpace(root["theme"].(string)); got != "dark-daltonized" {
t.Fatalf("expected theme to be preserved, got %q", got)
}
mcpServers, ok := root["mcpServers"].(map[string]any)
if !ok {
t.Fatalf("expected mcpServers object, got %#v", root["mcpServers"])
}
if _, ok := mcpServers["memory"]; !ok {
t.Fatalf("expected existing memory server to be preserved, got %#v", mcpServers)
}
gonavi, ok := mcpServers[gonaviMCPServerID].(map[string]any)
if !ok {
t.Fatalf("expected gonavi server object, got %#v", mcpServers[gonaviMCPServerID])
}
if got := strings.TrimSpace(gonavi["command"].(string)); got != `C:\Program Files\GoNavi\GoNavi.exe` {
t.Fatalf("expected gonavi command to be written, got %q", got)
}
args, ok := gonavi["args"].([]any)
if !ok || len(args) != 1 || strings.TrimSpace(args[0].(string)) != "mcp-server" {
t.Fatalf("expected gonavi args to contain mcp-server, got %#v", gonavi["args"])
}
}
func TestUpsertClaudeCodeMCPServerConfigRejectsInvalidMCPServersShape(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, ".claude.json")
if err := os.WriteFile(configPath, []byte("{\"mcpServers\":[]}"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
err := upsertClaudeCodeMCPServerConfig(configPath, gonaviMCPServerID, claudeCodeMCPServerConfig{
Type: "stdio",
Command: "GoNavi.exe",
})
if err == nil {
t.Fatal("expected invalid mcpServers shape to return error")
}
if !strings.Contains(err.Error(), "mcpServers should be an object") {
t.Fatalf("expected invalid shape error, got %v", err)
}
}
func TestParseCodexMCPServerConfigDetectsExistingInstall(t *testing.T) {
content := strings.Join([]string{
`model = "gpt-5.4"`,
``,
`[mcp_servers.gonavi]`,
`command = 'C:\Program Files\GoNavi\GoNavi.exe'`,
`args = ['mcp-server']`,
`startup_timeout_sec = 60`,
``,
`[projects.'D:\Work\CodeRepos\GoNavi']`,
`trust_level = "trusted"`,
``,
}, "\n")
cfg, found, err := parseCodexMCPServerConfig(content, gonaviMCPServerID)
if err != nil {
t.Fatalf("parseCodexMCPServerConfig returned error: %v", err)
}
if !found {
t.Fatal("expected gonavi install to be detected")
}
if cfg.Command != `C:\Program Files\GoNavi\GoNavi.exe` {
t.Fatalf("unexpected command: %q", cfg.Command)
}
if !reflect.DeepEqual(cfg.Args, []string{"mcp-server"}) {
t.Fatalf("unexpected args: %#v", cfg.Args)
}
if cfg.StartupTimeoutSec != 60 {
t.Fatalf("unexpected startup timeout: %d", cfg.StartupTimeoutSec)
}
}
func TestCodexMCPConfigParserInternalErrorsUseEnglishText(t *testing.T) {
hanText := regexp.MustCompile(`\p{Han}`)
cases := []struct {
name string
err error
want string
}{
{
name: "short string",
err: func() error { _, err := parseTOMLString("x"); return err }(),
want: "invalid string format",
},
{
name: "unclosed single quoted string",
err: func() error { _, err := parseTOMLString("'x"); return err }(),
want: "single-quoted string is not closed",
},
{
name: "non string",
err: func() error { _, err := parseTOMLString("[bad]"); return err }(),
want: "not a string",
},
{
name: "non array",
err: func() error { _, err := parseTOMLStringArray("'x'"); return err }(),
want: "not an array",
},
{
name: "invalid array separator",
err: func() error { _, err := parseTOMLStringArray("['a' 'b']"); return err }(),
want: "invalid array separator",
},
{
name: "empty array item",
err: func() error { _, _, err := consumeTOMLQuotedString(""); return err }(),
want: "string is empty",
},
{
name: "unclosed double quoted string",
err: func() error { _, _, err := consumeTOMLQuotedString(`"x`); return err }(),
want: "double-quoted string is not closed",
},
{
name: "non string array element",
err: func() error { _, err := decodeJSONLikeStringSlice([]any{"ok", 1}); return err }(),
want: "array element is not a string",
},
{
name: "non string array",
err: func() error { _, err := decodeJSONLikeStringSlice("x"); return err }(),
want: "not a string array",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.err == nil {
t.Fatal("expected parser helper to return an error")
}
if hanText.MatchString(tc.err.Error()) {
t.Fatalf("expected English internal parser error, got %q", tc.err.Error())
}
if tc.err.Error() != tc.want {
t.Fatalf("expected parser helper error %q, got %q", tc.want, tc.err.Error())
}
})
}
}
func TestUpsertCodexMCPServerConfigCreatesAndMergesConfig(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.toml")
initial := strings.Join([]string{
`model = "gpt-5.4"`,
``,
`[mcp_servers.memory]`,
`command = "cmd"`,
`args = ["/c", "npx"]`,
``,
}, "\n")
if err := os.WriteFile(configPath, []byte(initial), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
err := upsertCodexMCPServerConfig(configPath, gonaviMCPServerID, codexMCPServerConfig{
Command: `C:\Program Files\GoNavi\GoNavi.exe`,
Args: []string{"mcp-server"},
StartupTimeoutSec: defaultCodexMCPStartupTimeoutSecond,
})
if err != nil {
t.Fatalf("upsertCodexMCPServerConfig returned error: %v", err)
}
updated, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
text := string(updated)
if !strings.Contains(text, `[mcp_servers.memory]`) {
t.Fatalf("expected memory server to be preserved, got %s", text)
}
if !strings.Contains(text, `[mcp_servers.gonavi]`) {
t.Fatalf("expected gonavi section to be created, got %s", text)
}
if !strings.Contains(text, `command = 'C:\Program Files\GoNavi\GoNavi.exe'`) {
t.Fatalf("expected gonavi command to be written, got %s", text)
}
if !strings.Contains(text, `args = ['mcp-server']`) {
t.Fatalf("expected gonavi args to be written, got %s", text)
}
if !strings.Contains(text, `startup_timeout_sec = 60`) {
t.Fatalf("expected startup timeout to be written, got %s", text)
}
}
func TestUpsertCodexMCPServerConfigReplacesExistingBlockAndNestedSections(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.toml")
initial := strings.Join([]string{
`model = "gpt-5.4"`,
``,
`[mcp_servers.gonavi]`,
`command = 'old.exe'`,
`args = ['old']`,
`startup_timeout_sec = 15`,
``,
`[mcp_servers.gonavi.env]`,
`FOO = "bar"`,
``,
`[projects.'D:\Work\CodeRepos\GoNavi']`,
`trust_level = "trusted"`,
``,
}, "\n")
if err := os.WriteFile(configPath, []byte(initial), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
err := upsertCodexMCPServerConfig(configPath, gonaviMCPServerID, codexMCPServerConfig{
Command: `C:\Program Files\GoNavi\GoNavi.exe`,
Args: []string{"mcp-server"},
StartupTimeoutSec: defaultCodexMCPStartupTimeoutSecond,
})
if err != nil {
t.Fatalf("upsertCodexMCPServerConfig returned error: %v", err)
}
updated, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
text := string(updated)
if strings.Contains(text, `command = 'old.exe'`) || strings.Contains(text, `[mcp_servers.gonavi.env]`) {
t.Fatalf("expected old gonavi block to be replaced, got %s", text)
}
if !strings.Contains(text, `[projects.'D:\Work\CodeRepos\GoNavi']`) {
t.Fatalf("expected unrelated project config to be preserved, got %s", text)
}
}
func TestInspectClaudeCodeMCPInstallStatusIncludesLocalCLIAvailability(t *testing.T) {
originalConfigPathFunc := claudeCodeConfigPathFunc
originalCLIPathFunc := localCLICommandPathFunc
t.Cleanup(func() {
claudeCodeConfigPathFunc = originalConfigPathFunc
localCLICommandPathFunc = originalCLIPathFunc
})
tempDir := t.TempDir()
claudeCodeConfigPathFunc = func() (string, error) {
return filepath.Join(tempDir, ".claude.json"), nil
}
localCLICommandPathFunc = func(file string) (string, error) {
if file != claudeCodeClientCommandName {
t.Fatalf("expected lookup for %q, got %q", claudeCodeClientCommandName, file)
}
return `C:\Users\mock\AppData\Roaming\npm\claude.CMD`, nil
}
status := inspectClaudeCodeMCPInstallStatus(`C:\Program Files\GoNavi\GoNavi.exe`, []string{"mcp-server"}, nil)
if !status.ClientDetected {
t.Fatal("expected Claude Code command detection to be true")
}
if status.ClientCommand != claudeCodeClientCommandName {
t.Fatalf("expected client command %q, got %q", claudeCodeClientCommandName, status.ClientCommand)
}
if status.ClientPath != `C:\Users\mock\AppData\Roaming\npm\claude.CMD` {
t.Fatalf("unexpected client path: %q", status.ClientPath)
}
if status.Installed {
t.Fatal("expected MCP config to remain uninstalled when config file is absent")
}
}
func TestInspectCodexMCPInstallStatusKeepsMissingCLISignalSeparateFromConfigState(t *testing.T) {
originalConfigPathFunc := codexConfigPathFunc
originalCLIPathFunc := localCLICommandPathFunc
t.Cleanup(func() {
codexConfigPathFunc = originalConfigPathFunc
localCLICommandPathFunc = originalCLIPathFunc
})
tempDir := t.TempDir()
codexConfigPathFunc = func() (string, error) {
return filepath.Join(tempDir, "config.toml"), nil
}
localCLICommandPathFunc = func(file string) (string, error) {
if file != codexClientCommandName {
t.Fatalf("expected lookup for %q, got %q", codexClientCommandName, file)
}
return "", errors.New("not found")
}
status := inspectCodexMCPInstallStatus(`C:\Program Files\GoNavi\GoNavi.exe`, []string{"mcp-server"}, nil)
if status.ClientDetected {
t.Fatal("expected codex command detection to be false")
}
if status.ClientCommand != codexClientCommandName {
t.Fatalf("expected client command %q, got %q", codexClientCommandName, status.ClientCommand)
}
if status.ClientPath != "" {
t.Fatalf("expected missing codex command path to be empty, got %q", status.ClientPath)
}
if status.Message != "No Codex user-level GoNavi MCP configuration was detected" {
t.Fatalf("unexpected config message: %q", status.Message)
}
}
func TestMCPClientInstallResultMessagesUseServiceLanguage(t *testing.T) {
originalClaudeConfigPathFunc := claudeCodeConfigPathFunc
originalCodexConfigPathFunc := codexConfigPathFunc
originalExecutablePathFunc := localMCPExecutablePathFunc
t.Cleanup(func() {
claudeCodeConfigPathFunc = originalClaudeConfigPathFunc
codexConfigPathFunc = originalCodexConfigPathFunc
localMCPExecutablePathFunc = originalExecutablePathFunc
})
tempDir := t.TempDir()
claudeCodeConfigPathFunc = func() (string, error) {
return filepath.Join(tempDir, ".claude.json"), nil
}
codexConfigPathFunc = func() (string, error) {
return filepath.Join(tempDir, ".codex", "config.toml"), nil
}
localMCPExecutablePathFunc = func() (string, error) {
return `C:\Program Files\GoNavi\GoNavi.exe`, nil
}
service := NewService()
service.AISetLanguage(string(i18n.LanguageEnUS))
hanText := regexp.MustCompile(`\p{Han}`)
claudeResult, err := service.AIInstallClaudeCodeMCP()
if err != nil {
t.Fatalf("AIInstallClaudeCodeMCP returned error: %v", err)
}
if hanText.MatchString(claudeResult.Message) {
t.Fatalf("Claude Code install message should be localized to English, got %q", claudeResult.Message)
}
if !strings.Contains(claudeResult.Message, "Claude Code user-level MCP configuration") {
t.Fatalf("unexpected Claude Code install message: %q", claudeResult.Message)
}
codexResult, err := service.AIInstallCodexMCP()
if err != nil {
t.Fatalf("AIInstallCodexMCP returned error: %v", err)
}
if hanText.MatchString(codexResult.Message) {
t.Fatalf("Codex install message should be localized to English, got %q", codexResult.Message)
}
if !strings.Contains(codexResult.Message, "Codex user-level MCP configuration") {
t.Fatalf("unexpected Codex install message: %q", codexResult.Message)
}
}
func TestMCPClientInstallStatusMessagesUseServiceLanguage(t *testing.T) {
originalClaudeConfigPathFunc := claudeCodeConfigPathFunc
originalCodexConfigPathFunc := codexConfigPathFunc
originalExecutablePathFunc := localMCPExecutablePathFunc
originalCLIPathFunc := localCLICommandPathFunc
t.Cleanup(func() {
claudeCodeConfigPathFunc = originalClaudeConfigPathFunc
codexConfigPathFunc = originalCodexConfigPathFunc
localMCPExecutablePathFunc = originalExecutablePathFunc
localCLICommandPathFunc = originalCLIPathFunc
})
tempDir := t.TempDir()
claudeCodeConfigPathFunc = func() (string, error) {
return filepath.Join(tempDir, ".claude.json"), nil
}
codexConfigPathFunc = func() (string, error) {
return filepath.Join(tempDir, ".codex", "config.toml"), nil
}
localMCPExecutablePathFunc = func() (string, error) {
return `C:\Program Files\GoNavi\GoNavi.exe`, nil
}
localCLICommandPathFunc = func(string) (string, error) {
return "", errors.New("not found")
}
service := NewService()
service.AISetLanguage(string(i18n.LanguageEnUS))
statuses := service.AIGetMCPClientInstallStatuses()
hanText := regexp.MustCompile(`\p{Han}`)
for _, status := range statuses {
if hanText.MatchString(status.Message) {
t.Fatalf("%s status message should be localized to English, got %q", status.Client, status.Message)
}
}
if len(statuses) != 4 {
t.Fatalf("expected 4 MCP client statuses, got %d", len(statuses))
}
if !strings.Contains(statuses[0].Message, "No Claude Code user-level GoNavi MCP configuration") {
t.Fatalf("unexpected Claude Code status message: %q", statuses[0].Message)
}
if !strings.Contains(statuses[1].Message, "No Codex user-level GoNavi MCP configuration") {
t.Fatalf("unexpected Codex status message: %q", statuses[1].Message)
}
if !strings.Contains(statuses[2].Message, "usually runs in the cloud or a remote environment") {
t.Fatalf("unexpected remote client status message: %q", statuses[2].Message)
}
}
func TestMCPClientInstallConfigPathFailuresUseServiceLanguage(t *testing.T) {
originalClaudeConfigPathFunc := claudeCodeConfigPathFunc
originalCodexConfigPathFunc := codexConfigPathFunc
originalExecutablePathFunc := localMCPExecutablePathFunc
originalCLIPathFunc := localCLICommandPathFunc
t.Cleanup(func() {
claudeCodeConfigPathFunc = originalClaudeConfigPathFunc
codexConfigPathFunc = originalCodexConfigPathFunc
localMCPExecutablePathFunc = originalExecutablePathFunc
localCLICommandPathFunc = originalCLIPathFunc
})
claudeCodeConfigPathFunc = func() (string, error) {
return "", errors.New("claude config denied")
}
codexConfigPathFunc = func() (string, error) {
return "", errors.New("codex config denied")
}
localMCPExecutablePathFunc = func() (string, error) {
return `C:\Program Files\GoNavi\GoNavi.exe`, nil
}
localCLICommandPathFunc = func(string) (string, error) {
return "", errors.New("not found")
}
service := NewService()
service.AISetLanguage(string(i18n.LanguageEnUS))
hanText := regexp.MustCompile(`\p{Han}`)
if _, err := service.AIInstallClaudeCodeMCP(); err == nil {
t.Fatal("expected Claude Code install config path error")
} else if hanText.MatchString(err.Error()) || !strings.Contains(err.Error(), "Failed to locate Claude Code configuration") || !strings.Contains(err.Error(), "claude config denied") {
t.Fatalf("Claude Code install error should use English wrapper and keep raw detail, got %q", err.Error())
}
if _, err := service.AIInstallCodexMCP(); err == nil {
t.Fatal("expected Codex install config path error")
} else if hanText.MatchString(err.Error()) || !strings.Contains(err.Error(), "Failed to locate Codex configuration") || !strings.Contains(err.Error(), "codex config denied") {
t.Fatalf("Codex install error should use English wrapper and keep raw detail, got %q", err.Error())
}
statuses := service.AIGetMCPClientInstallStatuses()
if len(statuses) < 2 {
t.Fatalf("expected at least Claude Code and Codex statuses, got %d", len(statuses))
}
if hanText.MatchString(statuses[0].Message) || !strings.Contains(statuses[0].Message, "Failed to locate Claude Code configuration") || !strings.Contains(statuses[0].Message, "claude config denied") {
t.Fatalf("Claude Code status should use English wrapper and keep raw detail, got %q", statuses[0].Message)
}
if hanText.MatchString(statuses[1].Message) || !strings.Contains(statuses[1].Message, "Failed to locate Codex configuration") || !strings.Contains(statuses[1].Message, "codex config denied") {
t.Fatalf("Codex status should use English wrapper and keep raw detail, got %q", statuses[1].Message)
}
}
func TestMCPClientInstallHomeDirDetailUsesServiceLanguage(t *testing.T) {
originalClaudeConfigPathFunc := claudeCodeConfigPathFunc
originalCodexConfigPathFunc := codexConfigPathFunc
originalExecutablePathFunc := localMCPExecutablePathFunc
originalCLIPathFunc := localCLICommandPathFunc
t.Cleanup(func() {
claudeCodeConfigPathFunc = originalClaudeConfigPathFunc
codexConfigPathFunc = originalCodexConfigPathFunc
localMCPExecutablePathFunc = originalExecutablePathFunc
localCLICommandPathFunc = originalCLIPathFunc
})
claudeCodeConfigPathFunc = func() (string, error) {
return "", errMCPClientUserHomeDirUnavailable
}
codexConfigPathFunc = func() (string, error) {
return "", errMCPClientUserHomeDirUnavailable
}
localMCPExecutablePathFunc = func() (string, error) {
return `C:\Program Files\GoNavi\GoNavi.exe`, nil
}
localCLICommandPathFunc = func(string) (string, error) {
return "", errors.New("not found")
}
service := NewService()
service.AISetLanguage(string(i18n.LanguageEnUS))
hanText := regexp.MustCompile(`\p{Han}`)
if _, err := service.AIInstallClaudeCodeMCP(); err == nil {
t.Fatal("expected Claude Code install home-dir error")
} else if hanText.MatchString(err.Error()) || !strings.Contains(err.Error(), "Unable to determine the current user home directory") {
t.Fatalf("Claude Code install home-dir detail should be localized, got %q", err.Error())
}
if _, err := service.AIInstallCodexMCP(); err == nil {
t.Fatal("expected Codex install home-dir error")
} else if hanText.MatchString(err.Error()) || !strings.Contains(err.Error(), "Unable to determine the current user home directory") {
t.Fatalf("Codex install home-dir detail should be localized, got %q", err.Error())
}
statuses := service.AIGetMCPClientInstallStatuses()
if len(statuses) < 2 {
t.Fatalf("expected at least Claude Code and Codex statuses, got %d", len(statuses))
}
if hanText.MatchString(statuses[0].Message) || !strings.Contains(statuses[0].Message, "Unable to determine the current user home directory") {
t.Fatalf("Claude Code status home-dir detail should be localized, got %q", statuses[0].Message)
}
if hanText.MatchString(statuses[1].Message) || !strings.Contains(statuses[1].Message, "Unable to determine the current user home directory") {
t.Fatalf("Codex status home-dir detail should be localized, got %q", statuses[1].Message)
}
}
func TestMCPClientInstallExecutablePathFailuresUseServiceLanguage(t *testing.T) {
originalClaudeConfigPathFunc := claudeCodeConfigPathFunc
originalCodexConfigPathFunc := codexConfigPathFunc
originalExecutablePathFunc := localMCPExecutablePathFunc
originalCLIPathFunc := localCLICommandPathFunc
t.Cleanup(func() {
claudeCodeConfigPathFunc = originalClaudeConfigPathFunc
codexConfigPathFunc = originalCodexConfigPathFunc
localMCPExecutablePathFunc = originalExecutablePathFunc
localCLICommandPathFunc = originalCLIPathFunc
})
tempDir := t.TempDir()
claudeConfigPath := filepath.Join(tempDir, ".claude.json")
codexConfigPath := filepath.Join(tempDir, ".codex", "config.toml")
claudeCodeConfigPathFunc = func() (string, error) {
return claudeConfigPath, nil
}
codexConfigPathFunc = func() (string, error) {
return codexConfigPath, nil
}
localCLICommandPathFunc = func(string) (string, error) {
return "", errors.New("not found")
}
if err := os.WriteFile(claudeConfigPath, []byte(`{"mcpServers":{"gonavi":{"type":"stdio","command":"old-gonavi","args":["mcp-server"]}}}`), 0o644); err != nil {
t.Fatalf("WriteFile Claude config returned error: %v", err)
}
if err := os.MkdirAll(filepath.Dir(codexConfigPath), 0o755); err != nil {
t.Fatalf("MkdirAll Codex config dir returned error: %v", err)
}
if err := os.WriteFile(codexConfigPath, []byte("[mcp_servers.gonavi]\ncommand = 'old-gonavi'\nargs = ['mcp-server']\n"), 0o644); err != nil {
t.Fatalf("WriteFile Codex config returned error: %v", err)
}
service := NewService()
service.AISetLanguage(string(i18n.LanguageEnUS))
hanText := regexp.MustCompile(`\p{Han}`)
localMCPExecutablePathFunc = func() (string, error) {
return "", errors.New("executable lookup denied")
}
if _, err := service.AIInstallClaudeCodeMCP(); err == nil {
t.Fatal("expected Claude Code install executable path error")
} else if hanText.MatchString(err.Error()) || !strings.Contains(err.Error(), "Failed to locate the current GoNavi executable") || !strings.Contains(err.Error(), "executable lookup denied") {
t.Fatalf("Claude Code executable path error should use English wrapper and keep raw detail, got %q", err.Error())
}
statuses := service.AIGetMCPClientInstallStatuses()
if len(statuses) < 2 {
t.Fatalf("expected at least Claude Code and Codex statuses, got %d", len(statuses))
}
for _, status := range statuses[:2] {
if hanText.MatchString(status.Message) || !strings.Contains(status.Message, "Failed to locate the current GoNavi executable") || !strings.Contains(status.Message, "executable lookup denied") {
t.Fatalf("%s status executable path error should use English wrapper and keep raw detail, got %q", status.Client, status.Message)
}
}
localMCPExecutablePathFunc = func() (string, error) {
return " ", nil
}
if _, err := service.AIInstallCodexMCP(); err == nil {
t.Fatal("expected Codex install empty executable path error")
} else if hanText.MatchString(err.Error()) || !strings.Contains(err.Error(), "Current GoNavi executable path is empty") {
t.Fatalf("Codex empty executable path error should use English wrapper, got %q", err.Error())
}
statuses = service.AIGetMCPClientInstallStatuses()
for _, status := range statuses[:2] {
if hanText.MatchString(status.Message) || !strings.Contains(status.Message, "Current GoNavi executable path is empty") {
t.Fatalf("%s status empty executable path error should use English wrapper, got %q", status.Client, status.Message)
}
}
}
func TestMCPClientInstallConfigFormatFailuresUseServiceLanguage(t *testing.T) {
originalClaudeConfigPathFunc := claudeCodeConfigPathFunc
originalCodexConfigPathFunc := codexConfigPathFunc
originalExecutablePathFunc := localMCPExecutablePathFunc
originalCLIPathFunc := localCLICommandPathFunc
t.Cleanup(func() {
claudeCodeConfigPathFunc = originalClaudeConfigPathFunc
codexConfigPathFunc = originalCodexConfigPathFunc
localMCPExecutablePathFunc = originalExecutablePathFunc
localCLICommandPathFunc = originalCLIPathFunc
})
tempDir := t.TempDir()
claudeConfigPath := filepath.Join(tempDir, ".claude.json")
codexConfigPath := filepath.Join(tempDir, ".codex", "config.toml")
claudeCodeConfigPathFunc = func() (string, error) {
return claudeConfigPath, nil
}
codexConfigPathFunc = func() (string, error) {
return codexConfigPath, nil
}
localMCPExecutablePathFunc = func() (string, error) {
return `C:\Program Files\GoNavi\GoNavi.exe`, nil
}
localCLICommandPathFunc = func(string) (string, error) {
return "", errors.New("not found")
}
service := NewService()
service.AISetLanguage(string(i18n.LanguageEnUS))
hanText := regexp.MustCompile(`\p{Han}`)
if err := os.WriteFile(claudeConfigPath, []byte(`{"mcpServers":[]}`), 0o644); err != nil {
t.Fatalf("WriteFile Claude config returned error: %v", err)
}
if _, err := service.AIInstallClaudeCodeMCP(); err == nil {
t.Fatal("expected Claude Code invalid config shape error")
} else if hanText.MatchString(err.Error()) || !strings.Contains(err.Error(), "Claude Code configuration format is invalid") || !strings.Contains(err.Error(), "mcpServers") {
t.Fatalf("Claude Code config format error should use English wrapper and keep raw path segment, got %q", err.Error())
}
if err := os.WriteFile(claudeConfigPath, []byte(`{"mcpServers":{"gonavi":[]}}`), 0o644); err != nil {
t.Fatalf("WriteFile Claude config returned error: %v", err)
}
statuses := service.AIGetMCPClientInstallStatuses()
if len(statuses) < 2 {
t.Fatalf("expected at least Claude Code and Codex statuses, got %d", len(statuses))
}
if hanText.MatchString(statuses[0].Message) || !strings.Contains(statuses[0].Message, "Claude Code configuration format is invalid") || !strings.Contains(statuses[0].Message, "mcpServers.gonavi") {
t.Fatalf("Claude Code status format error should use English wrapper and keep raw path segment, got %q", statuses[0].Message)
}
if err := os.MkdirAll(filepath.Dir(codexConfigPath), 0o755); err != nil {
t.Fatalf("MkdirAll Codex config dir returned error: %v", err)
}
if err := os.WriteFile(codexConfigPath, []byte("[mcp_servers.gonavi]\ncommand = [bad]\n"), 0o644); err != nil {
t.Fatalf("WriteFile Codex config returned error: %v", err)
}
statuses = service.AIGetMCPClientInstallStatuses()
if hanText.MatchString(statuses[1].Message) || !strings.Contains(statuses[1].Message, "Codex configuration format is invalid") || !strings.Contains(statuses[1].Message, "mcp_servers.gonavi.command") {
t.Fatalf("Codex status format error should use English wrapper and keep raw path segment, got %q", statuses[1].Message)
}
}
func TestMCPClientInstallConfigIOFailuresUseServiceLanguage(t *testing.T) {
tempDir := t.TempDir()
service := NewService()
service.AISetLanguage(string(i18n.LanguageEnUS))
hanText := regexp.MustCompile(`\p{Han}`)
claudeConfigDir := filepath.Join(tempDir, "claude-dir")
if err := os.MkdirAll(claudeConfigDir, 0o755); err != nil {
t.Fatalf("MkdirAll Claude config dir returned error: %v", err)
}
if _, err := readClaudeCodeConfig(claudeConfigDir, service.serviceText); err == nil {
t.Fatal("expected Claude Code read config error")
} else if hanText.MatchString(err.Error()) || !strings.Contains(err.Error(), "Failed to read Claude Code configuration") {
t.Fatalf("Claude Code read error should use English wrapper, got %q", err.Error())
}
invalidClaudeConfigPath := filepath.Join(tempDir, ".claude-invalid.json")
if err := os.WriteFile(invalidClaudeConfigPath, []byte(`{"mcpServers":`), 0o644); err != nil {
t.Fatalf("WriteFile invalid Claude config returned error: %v", err)
}
if _, err := readClaudeCodeConfig(invalidClaudeConfigPath, service.serviceText); err == nil {
t.Fatal("expected Claude Code parse config error")
} else if hanText.MatchString(err.Error()) || !strings.Contains(err.Error(), "Failed to parse Claude Code configuration") {
t.Fatalf("Claude Code parse error should use English wrapper, got %q", err.Error())
}
blockingFile := filepath.Join(tempDir, "blocking-file")
if err := os.WriteFile(blockingFile, []byte("not a directory"), 0o644); err != nil {
t.Fatalf("WriteFile blocking file returned error: %v", err)
}
blockedClaudeConfigPath := filepath.Join(blockingFile, ".claude.json")
if err := upsertClaudeCodeMCPServerConfig(blockedClaudeConfigPath, gonaviMCPServerID, claudeCodeMCPServerConfig{
Type: "stdio",
Command: "GoNavi.exe",
}, service.serviceText); err == nil {
t.Fatal("expected Claude Code directory creation error")
} else if hanText.MatchString(err.Error()) || !strings.Contains(err.Error(), "Failed to create Claude Code configuration directory") {
t.Fatalf("Claude Code directory creation error should use English wrapper, got %q", err.Error())
}
codexConfigDir := filepath.Join(tempDir, "codex-dir")
if err := os.MkdirAll(codexConfigDir, 0o755); err != nil {
t.Fatalf("MkdirAll Codex config dir returned error: %v", err)
}
if _, _, err := readCodexMCPServerConfig(codexConfigDir, gonaviMCPServerID, service.serviceText); err == nil {
t.Fatal("expected Codex read config error")
} else if hanText.MatchString(err.Error()) || !strings.Contains(err.Error(), "Failed to read Codex configuration") {
t.Fatalf("Codex read error should use English wrapper, got %q", err.Error())
}
blockedCodexConfigPath := filepath.Join(blockingFile, "config.toml")
if err := upsertCodexMCPServerConfig(blockedCodexConfigPath, gonaviMCPServerID, codexMCPServerConfig{
Command: "GoNavi.exe",
Args: []string{"mcp-server"},
}, service.serviceText); err == nil {
t.Fatal("expected Codex directory creation error")
} else if hanText.MatchString(err.Error()) || !strings.Contains(err.Error(), "Failed to create Codex configuration directory") {
t.Fatalf("Codex directory creation error should use English wrapper, got %q", err.Error())
}
}
func TestMCPClientInstallMessageCatalogKeysExist(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("LoadCatalogs() error = %v", err)
}
keys := []string{
"ai.service.mcp_client.claude_code.install_success",
"ai.service.mcp_client.codex.install_success",
"ai.service.mcp_client.user_home_dir_unavailable",
"ai.service.mcp_client.claude_code.config_path_failed",
"ai.service.mcp_client.codex.config_path_failed",
"ai.service.mcp_client.executable_path_failed",
"ai.service.mcp_client.executable_path_empty",
"ai.service.mcp_client.claude_code.config_format_invalid",
"ai.service.mcp_client.codex.config_format_invalid",
"ai.service.mcp_client.claude_code.config_read_failed",
"ai.service.mcp_client.claude_code.config_parse_failed",
"ai.service.mcp_client.claude_code.config_serialize_failed",
"ai.service.mcp_client.claude_code.config_dir_create_failed",
"ai.service.mcp_client.claude_code.config_write_failed",
"ai.service.mcp_client.codex.config_read_failed",
"ai.service.mcp_client.codex.config_dir_create_failed",
"ai.service.mcp_client.codex.config_write_failed",
"ai.service.mcp_client.claude_code.status.missing",
"ai.service.mcp_client.codex.status.missing",
"ai.service.mcp_client.claude_code.status.path_check_failed",
"ai.service.mcp_client.codex.status.path_check_failed",
"ai.service.mcp_client.claude_code.status.connected",
"ai.service.mcp_client.codex.status.connected",
"ai.service.mcp_client.claude_code.status.path_mismatch",
"ai.service.mcp_client.codex.status.path_mismatch",
"ai.service.mcp_client.remote.status.message",
}
for _, language := range i18n.SupportedLanguages() {
catalog := catalogs[language]
for _, key := range keys {
if strings.TrimSpace(catalog[key]) == "" {
t.Fatalf("%s catalog missing MCP client install message key %q", language, key)
}
}
}
}