mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
* feat(mcp): add Model Context Protocol server support Add MCP client and provider packages that allow integrating external MCP tool servers into the review loop. Includes config commands for managing MCP servers, stdio subprocess integration tests, and comprehensive test coverage. * refactor(mcp): rename loop variable in contentToText to avoid shadowing Client receiver * fix(mcp): use platform-specific shell for setup command The MCP server setup command was hardcoded to use `sh -c`, which fails on Windows. Extract a `shellCommand` helper behind build tags to use `cmd /c` on Windows and `sh -c` elsewhere. * docs(mcp): add MCP server documentation to all README locales
1042 lines
29 KiB
Go
1042 lines
29 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"testing"
|
|
)
|
|
|
|
func TestSetConfigValueAuthHeaderNormalizesKnownValues(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "llm.auth_header", " bearer "); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
|
|
if cfg.Llm.AuthHeader != "authorization" {
|
|
t.Errorf("AuthHeader = %q, want %q", cfg.Llm.AuthHeader, "authorization")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueAuthHeaderRejectsCustomHeader(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "llm.auth_header", " X-Custom-Auth "); err == nil {
|
|
t.Fatal("expected error for unsupported auth_header, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProvider(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "provider", "anthropic"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Provider != "anthropic" {
|
|
t.Errorf("Provider = %q, want %q", cfg.Provider, "anthropic")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueModel(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "model", "claude-opus-4-6"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Model != "claude-opus-4-6" {
|
|
t.Errorf("Model = %q, want %q", cfg.Model, "claude-opus-4-6")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueModelWithProvider(t *testing.T) {
|
|
cfg := &Config{
|
|
Provider: "anthropic",
|
|
Providers: map[string]ProviderEntry{
|
|
"anthropic": {APIKey: "sk-test"},
|
|
},
|
|
}
|
|
|
|
if err := setConfigValue(cfg, "model", "claude-opus-4-6"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Providers["anthropic"].Model != "claude-opus-4-6" {
|
|
t.Errorf("entry Model = %q, want %q", cfg.Providers["anthropic"].Model, "claude-opus-4-6")
|
|
}
|
|
if cfg.Model != "" {
|
|
t.Errorf("top-level Model = %q, want empty (should write to provider entry)", cfg.Model)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderEntry(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "providers.anthropic.api_key", "sk-ant-test"); err != nil {
|
|
t.Fatalf("setConfigValue api_key: %v", err)
|
|
}
|
|
if cfg.Providers["anthropic"].APIKey != "sk-ant-test" {
|
|
t.Errorf("api_key = %q, want %q", cfg.Providers["anthropic"].APIKey, "sk-ant-test")
|
|
}
|
|
|
|
if err := setConfigValue(cfg, "providers.anthropic.model", "claude-opus-4-6"); err != nil {
|
|
t.Fatalf("setConfigValue model: %v", err)
|
|
}
|
|
if cfg.Providers["anthropic"].Model != "claude-opus-4-6" {
|
|
t.Errorf("model = %q, want %q", cfg.Providers["anthropic"].Model, "claude-opus-4-6")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderEntryNonPresetWritesCustomProvider(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "providers.my-gateway.url", "https://gateway.internal.com/v1"); err != nil {
|
|
t.Fatalf("setConfigValue url: %v", err)
|
|
}
|
|
|
|
if cfg.Providers != nil {
|
|
if _, ok := cfg.Providers["my-gateway"]; ok {
|
|
t.Fatal("non-preset providers.<name> should be stored in CustomProviders, not Providers")
|
|
}
|
|
}
|
|
if cfg.CustomProviders["my-gateway"].URL != "https://gateway.internal.com/v1" {
|
|
t.Errorf("custom provider URL = %q", cfg.CustomProviders["my-gateway"].URL)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderEntryModelsJSON(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "custom_providers.my-gateway.models", `["llama-3-70b","llama-3-8b","llama-3-70b"]`); err != nil {
|
|
t.Fatalf("setConfigValue models: %v", err)
|
|
}
|
|
|
|
got := cfg.CustomProviders["my-gateway"].Models
|
|
want := []string{"llama-3-70b", "llama-3-8b"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("models length = %d, want %d: %#v", len(got), len(want), got)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Errorf("models[%d] = %q, want %q", i, got[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderEntryModelsCommaSeparated(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "custom_providers.my-gateway.models", " llama-3-70b, llama-3-8b ,, llama-3-70b "); err != nil {
|
|
t.Fatalf("setConfigValue models: %v", err)
|
|
}
|
|
|
|
got := cfg.CustomProviders["my-gateway"].Models
|
|
want := []string{"llama-3-70b", "llama-3-8b"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("models length = %d, want %d: %#v", len(got), len(want), got)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Errorf("models[%d] = %q, want %q", i, got[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderEntryModelsUnquotedBracketList(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "custom_providers.my-gateway.models", "[llama-3-70b,llama-3-8b]"); err != nil {
|
|
t.Fatalf("setConfigValue models: %v", err)
|
|
}
|
|
|
|
got := cfg.CustomProviders["my-gateway"].Models
|
|
want := []string{"llama-3-70b", "llama-3-8b"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("models length = %d, want %d: %#v", len(got), len(want), got)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Errorf("models[%d] = %q, want %q", i, got[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderEntryProtocol(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "custom_providers.custom.protocol", "openai"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.CustomProviders["custom"].Protocol != "openai" {
|
|
t.Errorf("protocol = %q, want %q", cfg.CustomProviders["custom"].Protocol, "openai")
|
|
}
|
|
|
|
if err := setConfigValue(cfg, "custom_providers.custom.protocol", "invalid"); err == nil {
|
|
t.Fatal("expected error for invalid protocol")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderEntryInvalidKey(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "providers.anthropic.unknown_field", "value"); err == nil {
|
|
t.Fatal("expected error for unknown provider field")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderEntryInvalidPath(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "providers.anthropic", "value"); err == nil {
|
|
t.Fatal("expected error for incomplete provider path")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderEntryExtraBody(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "providers.anthropic.extra_body", `{"thinking":{"type":"disabled"}}`); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Providers["anthropic"].ExtraBody == nil {
|
|
t.Fatal("extra_body should not be nil")
|
|
}
|
|
if _, ok := cfg.Providers["anthropic"].ExtraBody["thinking"]; !ok {
|
|
t.Error("extra_body missing 'thinking' key")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueModelWithCustomProvider(t *testing.T) {
|
|
cfg := &Config{
|
|
Provider: "my-gateway",
|
|
CustomProviders: map[string]ProviderEntry{
|
|
"my-gateway": {URL: "https://gw.example.com/v1", Protocol: "openai"},
|
|
},
|
|
}
|
|
|
|
if err := setConfigValue(cfg, "model", "llama-3-70b"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.CustomProviders["my-gateway"].Model != "llama-3-70b" {
|
|
t.Errorf("entry Model = %q, want %q", cfg.CustomProviders["my-gateway"].Model, "llama-3-70b")
|
|
}
|
|
if cfg.Model != "" {
|
|
t.Errorf("top-level Model = %q, want empty (should write to custom provider entry)", cfg.Model)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmExtraHeaders(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "llm.extra_headers", "X-Custom=val1, X-Org=val2"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
|
|
if cfg.Llm.ExtraHeaders == nil {
|
|
t.Fatal("ExtraHeaders should not be nil")
|
|
}
|
|
if v := cfg.Llm.ExtraHeaders["X-Custom"]; v != "val1" {
|
|
t.Errorf("ExtraHeaders[\"X-Custom\"] = %q, want %q", v, "val1")
|
|
}
|
|
if v := cfg.Llm.ExtraHeaders["X-Org"]; v != "val2" {
|
|
t.Errorf("ExtraHeaders[\"X-Org\"] = %q, want %q", v, "val2")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmExtraHeadersInvalid(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "llm.extra_headers", "no-equals-sign"); err == nil {
|
|
t.Fatal("expected error for invalid extra headers, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmExtraHeadersReservedRejected(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "llm.extra_headers", "Authorization=bad"); err == nil {
|
|
t.Fatal("expected error for reserved header, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderExtraHeaders(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "providers.anthropic.extra_headers", "X-Custom=val1, X-Org=val2"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
|
|
entry := cfg.Providers["anthropic"]
|
|
if entry.ExtraHeaders == nil {
|
|
t.Fatal("ExtraHeaders should not be nil")
|
|
}
|
|
if v := entry.ExtraHeaders["X-Custom"]; v != "val1" {
|
|
t.Errorf("ExtraHeaders[\"X-Custom\"] = %q, want %q", v, "val1")
|
|
}
|
|
if v := entry.ExtraHeaders["X-Org"]; v != "val2" {
|
|
t.Errorf("ExtraHeaders[\"X-Org\"] = %q, want %q", v, "val2")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderExtraHeadersInvalid(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "providers.anthropic.extra_headers", "=missing-key"); err == nil {
|
|
t.Fatal("expected error for invalid extra headers, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueCustomProviderExtraHeaders(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "custom_providers.my-gateway.extra_headers", "X-Gateway=secret"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
|
|
entry := cfg.CustomProviders["my-gateway"]
|
|
if entry.ExtraHeaders == nil {
|
|
t.Fatal("ExtraHeaders should not be nil")
|
|
}
|
|
if v := entry.ExtraHeaders["X-Gateway"]; v != "secret" {
|
|
t.Errorf("ExtraHeaders[\"X-Gateway\"] = %q, want %q", v, "secret")
|
|
}
|
|
}
|
|
|
|
// --- unset tests ---
|
|
|
|
func TestParseConfigArgsUnset(t *testing.T) {
|
|
action, err := parseConfigArgs([]string{"unset", "custom_providers.my-gateway"})
|
|
if err != nil {
|
|
t.Fatalf("parseConfigArgs: %v", err)
|
|
}
|
|
if action.subCmd != "unset" {
|
|
t.Errorf("subCmd = %q, want %q", action.subCmd, "unset")
|
|
}
|
|
if action.key != "custom_providers.my-gateway" {
|
|
t.Errorf("key = %q, want %q", action.key, "custom_providers.my-gateway")
|
|
}
|
|
}
|
|
|
|
func TestParseConfigArgsUnsetMissingKey(t *testing.T) {
|
|
_, err := parseConfigArgs([]string{"unset"})
|
|
if err == nil {
|
|
t.Fatal("expected error for missing key")
|
|
}
|
|
}
|
|
|
|
func TestUnsetCustomProvider(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := dir + "/config.json"
|
|
|
|
cfg := &Config{
|
|
Provider: "anthropic",
|
|
CustomProviders: map[string]ProviderEntry{
|
|
"my-gateway": {URL: "https://gw.example.com/v1", Protocol: "openai", Model: "llama-3"},
|
|
},
|
|
}
|
|
if err := saveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("saveConfig: %v", err)
|
|
}
|
|
|
|
if err := unsetCustomProvider(configPath, "my-gateway"); err != nil {
|
|
t.Fatalf("unsetCustomProvider: %v", err)
|
|
}
|
|
|
|
cfg, err := loadOrCreateConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("reload: %v", err)
|
|
}
|
|
if cfg.CustomProviders != nil {
|
|
t.Errorf("CustomProviders should be nil after deleting the only entry, got %v", cfg.CustomProviders)
|
|
}
|
|
if cfg.Provider != "anthropic" {
|
|
t.Errorf("Provider = %q, want %q (should be untouched)", cfg.Provider, "anthropic")
|
|
}
|
|
}
|
|
|
|
func TestUnsetActiveCustomProvider(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := dir + "/config.json"
|
|
|
|
cfg := &Config{
|
|
Provider: "my-gateway",
|
|
Model: "fallback-model",
|
|
CustomProviders: map[string]ProviderEntry{
|
|
"my-gateway": {URL: "https://gw.example.com/v1", Protocol: "openai", Model: "llama-3"},
|
|
"other-gateway": {URL: "https://other.example.com/v1", Protocol: "openai", Model: "other-model"},
|
|
},
|
|
}
|
|
if err := saveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("saveConfig: %v", err)
|
|
}
|
|
|
|
if err := unsetCustomProvider(configPath, "my-gateway"); err != nil {
|
|
t.Fatalf("unsetCustomProvider: %v", err)
|
|
}
|
|
|
|
cfg, err := loadOrCreateConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("reload: %v", err)
|
|
}
|
|
if cfg.Provider != "" {
|
|
t.Errorf("Provider = %q, want empty after deleting active provider", cfg.Provider)
|
|
}
|
|
if cfg.Model != "" {
|
|
t.Errorf("Model = %q, want empty after deleting active provider", cfg.Model)
|
|
}
|
|
if _, exists := cfg.CustomProviders["my-gateway"]; exists {
|
|
t.Error("my-gateway should have been deleted")
|
|
}
|
|
if _, exists := cfg.CustomProviders["other-gateway"]; !exists {
|
|
t.Error("other-gateway should still exist")
|
|
}
|
|
}
|
|
|
|
func TestUnsetInvalidKey(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
wantErr bool
|
|
}{
|
|
{"my-gateway", false},
|
|
{"nonexistent", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := dir + "/config.json"
|
|
cfg := &Config{
|
|
CustomProviders: map[string]ProviderEntry{
|
|
"my-gateway": {URL: "https://gw.example.com/v1"},
|
|
},
|
|
}
|
|
if err := saveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("saveConfig: %v", err)
|
|
}
|
|
err := unsetCustomProvider(configPath, tt.name)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("unsetCustomProvider(%q): err=%v, wantErr=%v", tt.name, err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMergeModelLists(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
lists [][]string
|
|
want []string
|
|
}{
|
|
{"empty", nil, nil},
|
|
{"single list", [][]string{{"a", "b"}}, []string{"a", "b"}},
|
|
{"merge with dedup", [][]string{{"a", "b"}, {"b", "c"}}, []string{"a", "b", "c"}},
|
|
{"three lists", [][]string{{"x"}, {"y"}, {"x", "z"}}, []string{"x", "y", "z"}},
|
|
{"empty strings filtered", [][]string{{"a", "", "b"}}, []string{"a", "b"}},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := mergeModelLists(tc.lists...)
|
|
if len(got) != len(tc.want) {
|
|
t.Fatalf("mergeModelLists() = %v, want %v", got, tc.want)
|
|
}
|
|
for i := range tc.want {
|
|
if got[i] != tc.want[i] {
|
|
t.Errorf("[%d] = %q, want %q", i, got[i], tc.want[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_Command(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.command", "npx"); err != nil {
|
|
t.Fatalf("setMCPServerValue: %v", err)
|
|
}
|
|
if cfg.MCPServers["my-server"].Command != "npx" {
|
|
t.Errorf("Command = %q, want %q", cfg.MCPServers["my-server"].Command, "npx")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_CommandEmpty(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.command", ""); err == nil {
|
|
t.Fatal("expected error for empty command")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_Args(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.args", `["--port","8080"]`); err != nil {
|
|
t.Fatalf("setMCPServerValue: %v", err)
|
|
}
|
|
args := cfg.MCPServers["my-server"].Args
|
|
if len(args) != 2 || args[0] != "--port" || args[1] != "8080" {
|
|
t.Errorf("Args = %v", args)
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_ArgsInvalidJSON(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.args", "not-json"); err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_Env(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.env", `["FOO=bar","BAZ=qux"]`); err != nil {
|
|
t.Fatalf("setMCPServerValue: %v", err)
|
|
}
|
|
env := cfg.MCPServers["my-server"].Env
|
|
if len(env) != 2 || env[0] != "FOO=bar" {
|
|
t.Errorf("Env = %v", env)
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_EnvInvalidJSON(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.env", "not-json"); err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_EnvInvalidFormat(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.env", `["NOEQUALS"]`); err == nil {
|
|
t.Fatal("expected error for env entry without KEY=VALUE format")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_Tools(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.tools", `["search","read","search"]`); err != nil {
|
|
t.Fatalf("setMCPServerValue: %v", err)
|
|
}
|
|
tools := cfg.MCPServers["my-server"].Tools
|
|
if len(tools) != 2 || tools[0] != "search" || tools[1] != "read" {
|
|
t.Errorf("Tools = %v (expected deduped)", tools)
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_ToolsInvalidJSON(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.tools", "not-json"); err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_ToolsEmptyName(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.tools", `["search",""]`); err == nil {
|
|
t.Fatal("expected error for empty tool name")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_Setup(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.setup", "init-script.sh"); err != nil {
|
|
t.Fatalf("setMCPServerValue: %v", err)
|
|
}
|
|
if cfg.MCPServers["my-server"].Setup != "init-script.sh" {
|
|
t.Errorf("Setup = %q", cfg.MCPServers["my-server"].Setup)
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_UnknownField(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.unknown", "val"); err == nil {
|
|
t.Fatal("expected error for unknown field")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_InvalidKey(t *testing.T) {
|
|
cfg := &Config{}
|
|
tests := []string{
|
|
"mcp_servers",
|
|
"mcp_servers.",
|
|
"mcp_servers..command",
|
|
"mcp_servers.name",
|
|
}
|
|
for _, key := range tests {
|
|
if err := setMCPServerValue(cfg, key, "val"); err == nil {
|
|
t.Errorf("expected error for key %q", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_ExistingServer(t *testing.T) {
|
|
cfg := &Config{
|
|
MCPServers: map[string]MCPServerConfig{
|
|
"srv": {Command: "old-cmd"},
|
|
},
|
|
}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.srv.command", "new-cmd"); err != nil {
|
|
t.Fatalf("setMCPServerValue: %v", err)
|
|
}
|
|
if cfg.MCPServers["srv"].Command != "new-cmd" {
|
|
t.Errorf("Command = %q, want %q", cfg.MCPServers["srv"].Command, "new-cmd")
|
|
}
|
|
}
|
|
|
|
func TestUnsetMCPServer(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := dir + "/config.json"
|
|
|
|
cfg := &Config{
|
|
MCPServers: map[string]MCPServerConfig{
|
|
"srv1": {Command: "cmd1"},
|
|
"srv2": {Command: "cmd2"},
|
|
},
|
|
}
|
|
if err := saveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("saveConfig: %v", err)
|
|
}
|
|
|
|
if err := unsetMCPServer(configPath, "srv1"); err != nil {
|
|
t.Fatalf("unsetMCPServer: %v", err)
|
|
}
|
|
|
|
cfg, err := loadOrCreateConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("reload: %v", err)
|
|
}
|
|
if _, exists := cfg.MCPServers["srv1"]; exists {
|
|
t.Error("srv1 should have been deleted")
|
|
}
|
|
if _, exists := cfg.MCPServers["srv2"]; !exists {
|
|
t.Error("srv2 should still exist")
|
|
}
|
|
}
|
|
|
|
func TestUnsetMCPServer_LastEntry(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := dir + "/config.json"
|
|
|
|
cfg := &Config{
|
|
MCPServers: map[string]MCPServerConfig{
|
|
"only": {Command: "cmd"},
|
|
},
|
|
}
|
|
if err := saveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("saveConfig: %v", err)
|
|
}
|
|
|
|
if err := unsetMCPServer(configPath, "only"); err != nil {
|
|
t.Fatalf("unsetMCPServer: %v", err)
|
|
}
|
|
|
|
cfg, err := loadOrCreateConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("reload: %v", err)
|
|
}
|
|
if cfg.MCPServers != nil {
|
|
t.Errorf("MCPServers should be nil after deleting last entry, got %v", cfg.MCPServers)
|
|
}
|
|
}
|
|
|
|
func TestUnsetMCPServer_NotFound(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := dir + "/config.json"
|
|
|
|
cfg := &Config{}
|
|
if err := saveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("saveConfig: %v", err)
|
|
}
|
|
|
|
if err := unsetMCPServer(configPath, "nonexistent"); err == nil {
|
|
t.Fatal("expected error for nil MCPServers")
|
|
}
|
|
|
|
cfg = &Config{
|
|
MCPServers: map[string]MCPServerConfig{
|
|
"other": {Command: "cmd"},
|
|
},
|
|
}
|
|
if err := saveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("saveConfig: %v", err)
|
|
}
|
|
|
|
if err := unsetMCPServer(configPath, "nonexistent"); err == nil {
|
|
t.Fatal("expected error for missing server")
|
|
}
|
|
}
|
|
|
|
func TestRunConfigUnset_UnknownPrefix(t *testing.T) {
|
|
if err := runConfigUnset("providers.anthropic"); err == nil {
|
|
t.Fatal("expected error for unsupported prefix")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueMCPServer(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "mcp_servers.my-server.command", "npx"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.MCPServers["my-server"].Command != "npx" {
|
|
t.Errorf("Command = %q", cfg.MCPServers["my-server"].Command)
|
|
}
|
|
}
|
|
|
|
func TestEnsureTelemetry(t *testing.T) {
|
|
cfg := &Config{}
|
|
if cfg.Telemetry != nil {
|
|
t.Fatal("Telemetry should be nil initially")
|
|
}
|
|
cfg.ensureTelemetry()
|
|
if cfg.Telemetry == nil {
|
|
t.Fatal("Telemetry should be non-nil after ensureTelemetry()")
|
|
}
|
|
cfg.ensureTelemetry()
|
|
if cfg.Telemetry == nil {
|
|
t.Fatal("Telemetry should remain non-nil on second call")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmURL(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "llm.url", "https://example.com/v1"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Llm.URL != "https://example.com/v1" {
|
|
t.Errorf("URL = %q", cfg.Llm.URL)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmAuthToken(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "llm.auth_token", "tok-123"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Llm.AuthToken != "tok-123" {
|
|
t.Errorf("AuthToken = %q", cfg.Llm.AuthToken)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmModel(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "llm.model", "my-model"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Llm.Model != "my-model" {
|
|
t.Errorf("Model = %q", cfg.Llm.Model)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmUseAnthropic(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "llm.use_anthropic", "false"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Llm.UseAnthropic == nil || *cfg.Llm.UseAnthropic != false {
|
|
t.Errorf("UseAnthropic = %v", cfg.Llm.UseAnthropic)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmUseAnthropicInvalid(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "llm.use_anthropic", "notbool"); err == nil {
|
|
t.Fatal("expected error for invalid boolean")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLanguage(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "language", "English"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Language != "English" {
|
|
t.Errorf("Language = %q", cfg.Language)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueTelemetryEnabled(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "telemetry.enabled", "true"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Telemetry == nil || !cfg.Telemetry.Enabled {
|
|
t.Error("Telemetry.Enabled should be true")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueTelemetryEnabledInvalid(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "telemetry.enabled", "notbool"); err == nil {
|
|
t.Fatal("expected error for invalid boolean")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueTelemetryExporter(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "telemetry.exporter", "otlp"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Telemetry.Exporter != "otlp" {
|
|
t.Errorf("Exporter = %q", cfg.Telemetry.Exporter)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueTelemetryOTLPEndpoint(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "telemetry.otlp_endpoint", "localhost:4317"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Telemetry.OTLPEndpoint != "localhost:4317" {
|
|
t.Errorf("OTLPEndpoint = %q", cfg.Telemetry.OTLPEndpoint)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueTelemetryContentLogging(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "telemetry.content_logging", "true"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if !cfg.Telemetry.ContentLog {
|
|
t.Error("ContentLog should be true")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueTelemetryContentLoggingInvalid(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "telemetry.content_logging", "notbool"); err == nil {
|
|
t.Fatal("expected error for invalid boolean")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmExtraBody(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "llm.extra_body", `{"key":"val"}`); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Llm.ExtraBody == nil {
|
|
t.Fatal("ExtraBody should not be nil")
|
|
}
|
|
if cfg.Llm.ExtraBody["key"] != "val" {
|
|
t.Errorf("ExtraBody[\"key\"] = %v", cfg.Llm.ExtraBody["key"])
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmExtraBodyInvalid(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "llm.extra_body", "not-json"); err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueUnknownKey(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "unknown.key", "val"); err == nil {
|
|
t.Fatal("expected error for unknown key")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderClearsModel(t *testing.T) {
|
|
cfg := &Config{Provider: "old-provider", Model: "old-model"}
|
|
if err := setConfigValue(cfg, "provider", "new-provider"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Model != "" {
|
|
t.Errorf("Model should be cleared on provider change, got %q", cfg.Model)
|
|
}
|
|
}
|
|
|
|
func TestRunConfigUnset_InvalidKey(t *testing.T) {
|
|
if err := runConfigUnset("provider"); err == nil {
|
|
t.Fatal("expected error for non custom_providers key")
|
|
}
|
|
if err := runConfigUnset("custom_providers."); err == nil {
|
|
t.Fatal("expected error for empty provider name")
|
|
}
|
|
}
|
|
|
|
func TestRunConfig_EmptyArgs(t *testing.T) {
|
|
err := runConfig(nil)
|
|
if err != nil {
|
|
t.Fatalf("runConfig with nil args should print usage, got error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunConfig_ProviderWithArgs(t *testing.T) {
|
|
err := runConfig([]string{"provider", "extra"})
|
|
if err == nil {
|
|
t.Fatal("expected error when provider has args")
|
|
}
|
|
}
|
|
|
|
func TestRunConfig_ModelWithArgs(t *testing.T) {
|
|
err := runConfig([]string{"model", "extra"})
|
|
if err == nil {
|
|
t.Fatal("expected error when model has args")
|
|
}
|
|
}
|
|
|
|
func TestDeleteCustomProvider_NotFound(t *testing.T) {
|
|
cfg := &Config{}
|
|
_, err := deleteCustomProvider(cfg, "nonexistent")
|
|
if err == nil {
|
|
t.Fatal("expected error for nil CustomProviders")
|
|
}
|
|
|
|
cfg.CustomProviders = map[string]ProviderEntry{"other": {}}
|
|
_, err = deleteCustomProvider(cfg, "nonexistent")
|
|
if err == nil {
|
|
t.Fatal("expected error for missing provider")
|
|
}
|
|
}
|
|
|
|
func TestActiveModelForProvider(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
cfg *Config
|
|
provider string
|
|
entry ProviderEntry
|
|
want string
|
|
}{
|
|
{"entry model", nil, "p", ProviderEntry{Model: "m1"}, "m1"},
|
|
{"cfg model", &Config{Provider: "p", Model: "m2"}, "p", ProviderEntry{}, "m2"},
|
|
{"entry takes precedence", &Config{Provider: "p", Model: "m2"}, "p", ProviderEntry{Model: "m1"}, "m1"},
|
|
{"different provider", &Config{Provider: "other", Model: "m2"}, "p", ProviderEntry{}, ""},
|
|
{"no model", &Config{Provider: "p"}, "p", ProviderEntry{}, ""},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := activeModelForProvider(tc.cfg, tc.provider, tc.entry)
|
|
if got != tc.want {
|
|
t.Errorf("got %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNormalizeModelList(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
models []string
|
|
want []string
|
|
}{
|
|
{"dedup", []string{"a", "b", "a"}, []string{"a", "b"}},
|
|
{"trim spaces", []string{" a ", " b "}, []string{"a", "b"}},
|
|
{"filter empty", []string{"a", "", "b"}, []string{"a", "b"}},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := normalizeModelList(tc.models)
|
|
if len(got) != len(tc.want) {
|
|
t.Fatalf("got %v, want %v", got, tc.want)
|
|
}
|
|
for i := range tc.want {
|
|
if got[i] != tc.want[i] {
|
|
t.Errorf("[%d] = %q, want %q", i, got[i], tc.want[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseModelListValue(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value string
|
|
want int
|
|
}{
|
|
{"empty", "", 0},
|
|
{"json array", `["a","b"]`, 2},
|
|
{"comma separated", "a,b,c", 3},
|
|
{"bracket unquoted", "[a,b]", 2},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := parseModelListValue(tc.value)
|
|
if err != nil {
|
|
t.Fatalf("parseModelListValue: %v", err)
|
|
}
|
|
if len(got) != tc.want {
|
|
t.Errorf("got %d models, want %d: %v", len(got), tc.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveConfigPath_Default(t *testing.T) {
|
|
t.Setenv("OCR_CONFIG_PATH", "")
|
|
p, err := resolveConfigPath()
|
|
if err != nil {
|
|
t.Fatalf("resolveConfigPath: %v", err)
|
|
}
|
|
if p == "" {
|
|
t.Fatal("expected non-empty default config path")
|
|
}
|
|
}
|
|
|
|
func TestResolveConfigPath_Env(t *testing.T) {
|
|
t.Setenv("OCR_CONFIG_PATH", "/tmp/test-config.json")
|
|
p, err := resolveConfigPath()
|
|
if err != nil {
|
|
t.Fatalf("resolveConfigPath: %v", err)
|
|
}
|
|
if p != "/tmp/test-config.json" {
|
|
t.Errorf("path = %q, want /tmp/test-config.json", p)
|
|
}
|
|
}
|
|
|
|
func TestLoadOrCreateConfig_NewFile(t *testing.T) {
|
|
cfg, err := loadOrCreateConfig(t.TempDir() + "/nonexistent.json")
|
|
if err != nil {
|
|
t.Fatalf("loadOrCreateConfig: %v", err)
|
|
}
|
|
if cfg == nil {
|
|
t.Fatal("expected non-nil config")
|
|
}
|
|
}
|
|
|
|
func TestLoadOrCreateConfig_InvalidJSON(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := dir + "/bad.json"
|
|
if err := os.WriteFile(path, []byte("{invalid"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := loadOrCreateConfig(path)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestLoadAppConfig_NotExist(t *testing.T) {
|
|
cfg, err := LoadAppConfig(t.TempDir() + "/none.json")
|
|
if err != nil {
|
|
t.Fatalf("LoadAppConfig: %v", err)
|
|
}
|
|
if cfg != nil {
|
|
t.Fatal("expected nil config for non-existent file")
|
|
}
|
|
}
|
|
|
|
func TestLoadAppConfig_InvalidJSON(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := dir + "/bad.json"
|
|
if err := os.WriteFile(path, []byte("not json"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := LoadAppConfig(path)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestEnsureModelInList(t *testing.T) {
|
|
models := []string{"test-model", "test-model-2", "bbb", "aaa", "test-model-3"}
|
|
|
|
got := ensureModelInList(models, "test-model-3")
|
|
if len(got) != len(models) {
|
|
t.Fatalf("existing model should not reorder: got %v", got)
|
|
}
|
|
for i := range models {
|
|
if got[i] != models[i] {
|
|
t.Errorf("models[%d] = %q, want %q", i, got[i], models[i])
|
|
}
|
|
}
|
|
|
|
got = ensureModelInList(models, "new-model")
|
|
want := append(append([]string(nil), models...), "new-model")
|
|
if len(got) != len(want) || got[len(got)-1] != "new-model" {
|
|
t.Errorf("new model should append: got %v, want %v", got, want)
|
|
}
|
|
}
|