Rebuild the Wails desktop shell into a localized tabbed control panel so provider, alias, sync, and settings workflows are easier to manage in one place. Extend desktop preferences with theme and language, document the desktop build flow, and keep the browser fallback shell aligned with the same frontend.
372 lines
10 KiB
Go
372 lines
10 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Apale7/opencode-provider-switch/internal/config"
|
|
)
|
|
|
|
func TestSaveDesktopPrefsPersistsToConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
|
svc := NewService(path)
|
|
|
|
prefs, err := svc.SaveDesktopPrefs(context.Background(), DesktopPrefsInput{
|
|
LaunchAtLogin: true,
|
|
MinimizeToTray: true,
|
|
Notifications: true,
|
|
Theme: "dark",
|
|
Language: "zh-CN",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SaveDesktopPrefs() error = %v", err)
|
|
}
|
|
if !prefs.LaunchAtLogin || !prefs.MinimizeToTray || !prefs.Notifications || prefs.Theme != "dark" || prefs.Language != "zh-CN" {
|
|
t.Fatalf("SaveDesktopPrefs() = %#v", prefs)
|
|
}
|
|
|
|
cfg, err := config.Load(path)
|
|
if err != nil {
|
|
t.Fatalf("config.Load() error = %v", err)
|
|
}
|
|
if !cfg.Desktop.LaunchAtLogin || !cfg.Desktop.MinimizeToTray || !cfg.Desktop.Notifications || cfg.Desktop.Theme != "dark" || cfg.Desktop.Language != "zh-CN" {
|
|
t.Fatalf("persisted desktop prefs = %#v", cfg.Desktop)
|
|
}
|
|
}
|
|
|
|
func TestSaveDesktopPrefsNormalizesUnknownThemeAndLanguage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
|
svc := NewService(path)
|
|
|
|
prefs, err := svc.SaveDesktopPrefs(context.Background(), DesktopPrefsInput{
|
|
Theme: "night-mode",
|
|
Language: "fr-FR",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SaveDesktopPrefs() error = %v", err)
|
|
}
|
|
if prefs.Theme != "system" || prefs.Language != "system" {
|
|
t.Fatalf("normalized prefs = %#v", prefs)
|
|
}
|
|
}
|
|
|
|
func TestStartStopProxyUpdatesStatus(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
|
cfgPathPort := freePort(t)
|
|
cfg, err := config.Load(path)
|
|
if err != nil {
|
|
t.Fatalf("config.Load() error = %v", err)
|
|
}
|
|
cfg.Server.Port = cfgPathPort
|
|
cfg.Server.Host = "127.0.0.1"
|
|
cfg.Server.APIKey = config.DefaultLocalAPIKey
|
|
if err := cfg.Save(); err != nil {
|
|
t.Fatalf("cfg.Save() error = %v", err)
|
|
}
|
|
|
|
svc := NewService(path)
|
|
if err := svc.StartProxy(context.Background()); err != nil {
|
|
t.Fatalf("StartProxy() error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = svc.StopProxy(context.Background())
|
|
})
|
|
|
|
status, err := svc.GetProxyStatus(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetProxyStatus() error = %v", err)
|
|
}
|
|
if !status.Running {
|
|
t.Fatalf("status.Running = false, want true")
|
|
}
|
|
|
|
assertEventually(t, func() bool {
|
|
resp, err := http.Get("http://127.0.0.1:" + itoa(cfgPathPort) + "/healthz")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer resp.Body.Close()
|
|
return resp.StatusCode == http.StatusOK
|
|
})
|
|
|
|
if err := svc.StopProxy(context.Background()); err != nil {
|
|
t.Fatalf("StopProxy() error = %v", err)
|
|
}
|
|
|
|
status, err = svc.GetProxyStatus(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetProxyStatus() after stop error = %v", err)
|
|
}
|
|
if status.Running {
|
|
t.Fatalf("status.Running = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestUpsertProviderReturnsWarningsAndKeepsCatalog(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
|
cfg, err := config.Load(path)
|
|
if err != nil {
|
|
t.Fatalf("config.Load() error = %v", err)
|
|
}
|
|
cfg.UpsertProvider(config.Provider{
|
|
ID: "relay",
|
|
BaseURL: "https://old.example.com/v1",
|
|
APIKey: "sk-old",
|
|
Models: []string{"gpt-4.1"},
|
|
ModelsSource: "discovered",
|
|
})
|
|
if err := cfg.Save(); err != nil {
|
|
t.Fatalf("cfg.Save() error = %v", err)
|
|
}
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/models" {
|
|
t.Fatalf("path = %q, want %q", r.URL.Path, "/v1/models")
|
|
}
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
_, _ = w.Write([]byte(`{"error":"upstream unavailable"}`))
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
svc := NewService(path)
|
|
result, err := svc.UpsertProvider(context.Background(), ProviderUpsertInput{
|
|
ID: "relay",
|
|
BaseURL: upstream.URL + "/v1",
|
|
APIKey: "sk-new",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpsertProvider() error = %v", err)
|
|
}
|
|
if result.Provider.BaseURL != upstream.URL+"/v1" {
|
|
t.Fatalf("saved baseUrl = %q, want %q", result.Provider.BaseURL, upstream.URL+"/v1")
|
|
}
|
|
if !containsWarning(result.Warnings, "model discovery failed") {
|
|
t.Fatalf("warnings %#v do not mention stale catalog preservation", result.Warnings)
|
|
}
|
|
if !containsWarning(result.Warnings, "could not discover provider models") {
|
|
t.Fatalf("warnings %#v do not mention discovery failure", result.Warnings)
|
|
}
|
|
|
|
reloaded, err := config.Load(path)
|
|
if err != nil {
|
|
t.Fatalf("config.Load() error = %v", err)
|
|
}
|
|
provider := reloaded.FindProvider("relay")
|
|
if provider == nil {
|
|
t.Fatal("provider relay not found after save")
|
|
}
|
|
if provider.ModelsSource != "" {
|
|
t.Fatalf("provider.ModelsSource = %q, want empty", provider.ModelsSource)
|
|
}
|
|
if len(provider.Models) != 1 || provider.Models[0] != "gpt-4.1" {
|
|
t.Fatalf("provider.Models = %#v, want existing catalog kept", provider.Models)
|
|
}
|
|
}
|
|
|
|
func TestImportProvidersReturnsWarnings(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "ocswitch.json")
|
|
sourcePath := filepath.Join(dir, "opencode.json")
|
|
cfg, err := config.Load(path)
|
|
if err != nil {
|
|
t.Fatalf("config.Load() error = %v", err)
|
|
}
|
|
cfg.UpsertProvider(config.Provider{ID: "keep", BaseURL: "https://existing.example.com/v1"})
|
|
if err := cfg.Save(); err != nil {
|
|
t.Fatalf("cfg.Save() error = %v", err)
|
|
}
|
|
if err := os.WriteFile(sourcePath, []byte(`{
|
|
"provider": {
|
|
"keep": {
|
|
"npm": "@ai-sdk/openai",
|
|
"options": {"baseURL": "https://duplicate.example.com/v1", "apiKey": "sk-dup"}
|
|
},
|
|
"broken": {
|
|
"npm": "@ai-sdk/openai",
|
|
"options": {"baseURL": "https://broken.example.com", "apiKey": "sk-bad"}
|
|
},
|
|
"fresh": {
|
|
"npm": "@ai-sdk/openai",
|
|
"name": "Fresh",
|
|
"options": {"baseURL": "https://fresh.example.com/v1", "apiKey": "sk-fresh"},
|
|
"models": {"gpt-4.1": {}}
|
|
}
|
|
}
|
|
}`), 0o600); err != nil {
|
|
t.Fatalf("os.WriteFile() error = %v", err)
|
|
}
|
|
|
|
svc := NewService(path)
|
|
result, err := svc.ImportProviders(context.Background(), ProviderImportInput{SourcePath: sourcePath})
|
|
if err != nil {
|
|
t.Fatalf("ImportProviders() error = %v", err)
|
|
}
|
|
if result.Imported != 1 || result.Skipped != 2 {
|
|
t.Fatalf("ImportProviders() = %#v, want imported=1 skipped=2", result)
|
|
}
|
|
if !containsWarning(result.Warnings, `skip "keep"`) {
|
|
t.Fatalf("warnings %#v do not mention duplicate provider", result.Warnings)
|
|
}
|
|
if !containsWarning(result.Warnings, `skip "broken"`) {
|
|
t.Fatalf("warnings %#v do not mention invalid provider", result.Warnings)
|
|
}
|
|
|
|
reloaded, err := config.Load(path)
|
|
if err != nil {
|
|
t.Fatalf("config.Load() error = %v", err)
|
|
}
|
|
if provider := reloaded.FindProvider("fresh"); provider == nil {
|
|
t.Fatal("provider fresh not imported")
|
|
}
|
|
}
|
|
|
|
func TestSetAliasTargetDisabledPersistsState(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
|
cfg, err := config.Load(path)
|
|
if err != nil {
|
|
t.Fatalf("config.Load() error = %v", err)
|
|
}
|
|
cfg.UpsertProvider(config.Provider{ID: "relay", BaseURL: "https://relay.example.com/v1"})
|
|
cfg.UpsertAlias(config.Alias{
|
|
Alias: "chat",
|
|
Enabled: true,
|
|
Targets: []config.Target{{Provider: "relay", Model: "gpt-4.1", Enabled: true}},
|
|
})
|
|
if err := cfg.Save(); err != nil {
|
|
t.Fatalf("cfg.Save() error = %v", err)
|
|
}
|
|
|
|
svc := NewService(path)
|
|
disabled, err := svc.SetAliasTargetDisabled(context.Background(), AliasTargetInput{
|
|
Alias: "chat",
|
|
Provider: "relay",
|
|
Model: "gpt-4.1",
|
|
Disabled: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SetAliasTargetDisabled(disable) error = %v", err)
|
|
}
|
|
if disabled.AvailableTargetCount != 0 || disabled.Targets[0].Enabled {
|
|
t.Fatalf("disabled alias view = %#v", disabled)
|
|
}
|
|
|
|
enabled, err := svc.SetAliasTargetDisabled(context.Background(), AliasTargetInput{
|
|
Alias: "chat",
|
|
Provider: "relay",
|
|
Model: "gpt-4.1",
|
|
Disabled: false,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SetAliasTargetDisabled(enable) error = %v", err)
|
|
}
|
|
if enabled.AvailableTargetCount != 1 || !enabled.Targets[0].Enabled {
|
|
t.Fatalf("enabled alias view = %#v", enabled)
|
|
}
|
|
|
|
reloaded, err := config.Load(path)
|
|
if err != nil {
|
|
t.Fatalf("config.Load() error = %v", err)
|
|
}
|
|
alias := reloaded.FindAlias("chat")
|
|
if alias == nil {
|
|
t.Fatal("alias chat not found after update")
|
|
}
|
|
if len(alias.Targets) != 1 || !alias.Targets[0].Enabled {
|
|
t.Fatalf("persisted alias targets = %#v", alias.Targets)
|
|
}
|
|
}
|
|
|
|
func TestUpsertAliasCanReEnableExistingAlias(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
|
cfg, err := config.Load(path)
|
|
if err != nil {
|
|
t.Fatalf("config.Load() error = %v", err)
|
|
}
|
|
cfg.UpsertAlias(config.Alias{Alias: "chat", DisplayName: "Chat", Enabled: false})
|
|
if err := cfg.Save(); err != nil {
|
|
t.Fatalf("cfg.Save() error = %v", err)
|
|
}
|
|
|
|
svc := NewService(path)
|
|
alias, err := svc.UpsertAlias(context.Background(), AliasUpsertInput{
|
|
Alias: "chat",
|
|
DisplayName: "Chat enabled",
|
|
Disabled: false,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpsertAlias() error = %v", err)
|
|
}
|
|
if !alias.Enabled {
|
|
t.Fatalf("alias.Enabled = false, want true: %#v", alias)
|
|
}
|
|
if alias.DisplayName != "Chat enabled" {
|
|
t.Fatalf("alias.DisplayName = %q, want %q", alias.DisplayName, "Chat enabled")
|
|
}
|
|
|
|
reloaded, err := config.Load(path)
|
|
if err != nil {
|
|
t.Fatalf("config.Load() error = %v", err)
|
|
}
|
|
persisted := reloaded.FindAlias("chat")
|
|
if persisted == nil || !persisted.Enabled {
|
|
t.Fatalf("persisted alias = %#v, want enabled", persisted)
|
|
}
|
|
}
|
|
|
|
func containsWarning(warnings []string, want string) bool {
|
|
for _, warning := range warnings {
|
|
if strings.Contains(warning, want) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func assertEventually(t *testing.T, fn func() bool) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if fn() {
|
|
return
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
t.Fatal("condition not satisfied before timeout")
|
|
}
|
|
|
|
func freePort(t *testing.T) int {
|
|
t.Helper()
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("net.Listen() error = %v", err)
|
|
}
|
|
defer listener.Close()
|
|
return listener.Addr().(*net.TCPAddr).Port
|
|
}
|
|
|
|
func itoa(v int) string {
|
|
return strconv.Itoa(v)
|
|
}
|