mirror of
https://github.com/ollama/ollama.git
synced 2026-05-21 13:11:39 -05:00
1459 lines
45 KiB
Go
1459 lines
45 KiB
Go
package launch
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/ollama/ollama/cmd/internal/fileutil"
|
|
)
|
|
|
|
func withCodexAppPlatform(t *testing.T, goos string) {
|
|
t.Helper()
|
|
old := codexAppGOOS
|
|
codexAppGOOS = goos
|
|
t.Cleanup(func() {
|
|
codexAppGOOS = old
|
|
})
|
|
}
|
|
|
|
func withCodexAppProcessHooks(t *testing.T, isRunning func() bool, quit func() error, open func() error) {
|
|
t.Helper()
|
|
oldIsRunning := codexAppIsRunning
|
|
oldQuit := codexAppQuitApp
|
|
oldOpen := codexAppOpenApp
|
|
oldOpenPath := codexAppOpenPath
|
|
oldOpenStart := codexAppOpenStart
|
|
oldForceQuit := codexAppForceQuit
|
|
oldHasWindow := codexAppHasWindow
|
|
oldRunPath := codexAppRunPath
|
|
oldStartID := codexAppStartID
|
|
oldCanOpenID := codexAppCanOpenID
|
|
oldExitTimeout := codexAppExitTimeout
|
|
oldForceExitTimeout := codexAppForceExitTimeout
|
|
codexAppIsRunning = isRunning
|
|
codexAppHasWindow = isRunning
|
|
codexAppQuitApp = quit
|
|
codexAppOpenApp = open
|
|
t.Cleanup(func() {
|
|
codexAppIsRunning = oldIsRunning
|
|
codexAppQuitApp = oldQuit
|
|
codexAppOpenApp = oldOpen
|
|
codexAppOpenPath = oldOpenPath
|
|
codexAppOpenStart = oldOpenStart
|
|
codexAppForceQuit = oldForceQuit
|
|
codexAppHasWindow = oldHasWindow
|
|
codexAppRunPath = oldRunPath
|
|
codexAppStartID = oldStartID
|
|
codexAppCanOpenID = oldCanOpenID
|
|
codexAppExitTimeout = oldExitTimeout
|
|
codexAppForceExitTimeout = oldForceExitTimeout
|
|
})
|
|
}
|
|
|
|
func TestCodexAppIntegration(t *testing.T) {
|
|
c := &CodexApp{}
|
|
|
|
t.Run("implements runner", func(t *testing.T) {
|
|
var _ Runner = c
|
|
})
|
|
t.Run("implements supported integration", func(t *testing.T) {
|
|
var _ SupportedIntegration = c
|
|
})
|
|
t.Run("implements managed single model", func(t *testing.T) {
|
|
var _ ManagedSingleModel = c
|
|
})
|
|
t.Run("receives model list", func(t *testing.T) {
|
|
var _ ManagedModelListConfigurer = c
|
|
})
|
|
t.Run("onboarding is noninteractive", func(t *testing.T) {
|
|
var _ ManagedInteractiveOnboarding = c
|
|
if c.RequiresInteractiveOnboarding() {
|
|
t.Fatal("Codex App onboarding should only mark launch config")
|
|
}
|
|
})
|
|
t.Run("implements restore", func(t *testing.T) {
|
|
var _ RestorableIntegration = c
|
|
var _ RestoreHintIntegration = c
|
|
var _ ConfigurationSuccessIntegration = c
|
|
var _ RestoreSuccessIntegration = c
|
|
})
|
|
}
|
|
|
|
func TestCodexAppSupportedPlatforms(t *testing.T) {
|
|
for _, goos := range []string{"darwin", "windows"} {
|
|
t.Run(goos, func(t *testing.T) {
|
|
withCodexAppPlatform(t, goos)
|
|
if err := codexAppSupported(); err != nil {
|
|
t.Fatalf("codexAppSupported returned error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("linux unsupported", func(t *testing.T) {
|
|
withCodexAppPlatform(t, "linux")
|
|
err := codexAppSupported()
|
|
if err == nil || !strings.Contains(err.Error(), "macOS and Windows") {
|
|
t.Fatalf("codexAppSupported error = %v, want platform message", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCodexAppWindowsAppPathCandidates(t *testing.T) {
|
|
withCodexAppPlatform(t, "windows")
|
|
local := filepath.Join(t.TempDir(), "LocalAppData")
|
|
t.Setenv("LOCALAPPDATA", local)
|
|
|
|
exe := filepath.Join(local, "Codex", "app-26.429.30905", "Codex.exe")
|
|
if err := os.MkdirAll(filepath.Dir(exe), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(exe, []byte{}, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if got := codexAppAppPath(); got != exe {
|
|
t.Fatalf("codexAppAppPath = %q, want %q", got, exe)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppInstalledUsesWindowsStartMenuFallback(t *testing.T) {
|
|
withCodexAppPlatform(t, "windows")
|
|
t.Setenv("LOCALAPPDATA", filepath.Join(t.TempDir(), "LocalAppData"))
|
|
|
|
oldStartID := codexAppStartID
|
|
oldIsRunning := codexAppIsRunning
|
|
codexAppStartID = func() string { return "OpenAI.Codex_12345!App" }
|
|
codexAppIsRunning = func() bool { return false }
|
|
t.Cleanup(func() {
|
|
codexAppStartID = oldStartID
|
|
codexAppIsRunning = oldIsRunning
|
|
})
|
|
|
|
if !codexAppInstalled() {
|
|
t.Fatal("expected Windows Start menu app id to count as installed")
|
|
}
|
|
}
|
|
|
|
func TestCodexAppInstalledUsesMacBundleIDFallback(t *testing.T) {
|
|
withCodexAppPlatform(t, "darwin")
|
|
|
|
oldCanOpenID := codexAppCanOpenID
|
|
oldStat := codexAppStat
|
|
codexAppCanOpenID = func() bool { return true }
|
|
codexAppStat = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }
|
|
t.Cleanup(func() {
|
|
codexAppCanOpenID = oldCanOpenID
|
|
codexAppStat = oldStat
|
|
})
|
|
|
|
if !codexAppInstalled() {
|
|
t.Fatal("expected macOS LaunchServices bundle id fallback to count as installed")
|
|
}
|
|
}
|
|
|
|
func TestCodexAppConfigureActivatesOllamaProfile(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
setTestHome(t, tmpDir)
|
|
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999")
|
|
|
|
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
existing := "" +
|
|
"profile = \"default\"\n" +
|
|
"model = \"gpt-5.5\"\n\n" +
|
|
"[profiles.default]\n" +
|
|
"model = \"gpt-5.5\"\n"
|
|
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
c := &CodexApp{}
|
|
if err := c.ConfigureWithModels("llama3.2", []string{"llama3.2", "qwen3:8b"}); err != nil {
|
|
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content := string(data)
|
|
catalogPath, err := codexAppModelCatalogPath()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, want := range []string{
|
|
fmt.Sprintf(`profile = %q`, codexAppProfileName),
|
|
`model = "llama3.2"`,
|
|
fmt.Sprintf(`model_provider = %q`, codexAppProfileName),
|
|
fmt.Sprintf(`model_catalog_json = %q`, catalogPath),
|
|
codexProfileHeaderFor(codexAppProfileName),
|
|
`model = "llama3.2"`,
|
|
`openai_base_url = "http://127.0.0.1:9999/v1/"`,
|
|
fmt.Sprintf(`model_provider = %q`, codexAppProfileName),
|
|
`model_catalog_json = "`,
|
|
codexProviderHeaderFor(codexAppProfileName),
|
|
`name = "Ollama"`,
|
|
`base_url = "http://127.0.0.1:9999/v1/"`,
|
|
`wire_api = "responses"`,
|
|
`[profiles.default]`,
|
|
} {
|
|
if !strings.Contains(content, want) {
|
|
t.Fatalf("expected config to contain %q, got:\n%s", want, content)
|
|
}
|
|
}
|
|
if got := c.CurrentModel(); got != "llama3.2" {
|
|
t.Fatalf("CurrentModel = %q, want llama3.2", got)
|
|
}
|
|
|
|
restoreData, err := os.ReadFile(codexAppRestoreStatePath())
|
|
if err != nil {
|
|
t.Fatalf("expected restore state: %v", err)
|
|
}
|
|
if !strings.Contains(string(restoreData), `"profile": "default"`) {
|
|
t.Fatalf("expected restore state to remember default profile, got %s", restoreData)
|
|
}
|
|
catalogData, err := os.ReadFile(catalogPath)
|
|
if err != nil {
|
|
t.Fatalf("expected model catalog: %v", err)
|
|
}
|
|
var catalog struct {
|
|
Models []map[string]any `json:"models"`
|
|
}
|
|
if err := json.Unmarshal(catalogData, &catalog); err != nil {
|
|
t.Fatalf("catalog should be valid JSON: %v", err)
|
|
}
|
|
if got := catalogSlugs(catalog.Models); strings.Join(got, ",") != "llama3.2,qwen3:8b" {
|
|
t.Fatalf("catalog slugs = %v, want fallback models", got)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppConfigureUsesAppSpecificProfileWithoutTouchingCLIProfile(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
setTestHome(t, tmpDir)
|
|
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999")
|
|
|
|
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
existing := "" +
|
|
`profile = "default"` + "\n\n" +
|
|
"[profiles.ollama-launch]\n" +
|
|
`model = "cli-model"` + "\n" +
|
|
`openai_base_url = "http://cli.invalid/v1/"` + "\n" +
|
|
`model_provider = "ollama-launch"` + "\n\n" +
|
|
"[model_providers.ollama-launch]\n" +
|
|
`name = "CLI Ollama"` + "\n" +
|
|
`base_url = "http://cli.invalid/v1/"` + "\n" +
|
|
`wire_api = "responses"` + "\n\n" +
|
|
"[profiles.default]\n" +
|
|
`model = "gpt-5.5"` + "\n"
|
|
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil {
|
|
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content := string(data)
|
|
if got := codexRootStringValue(content, "profile"); got != codexAppProfileName {
|
|
t.Fatalf("root profile = %q, want %q", got, codexAppProfileName)
|
|
}
|
|
if got := codexSectionStringValue(content, codexProfileHeader(), "openai_base_url"); got != "http://cli.invalid/v1/" {
|
|
t.Fatalf("CLI profile base URL = %q, want preserved CLI URL in:\n%s", got, content)
|
|
}
|
|
if got := codexSectionStringValue(content, codexProviderHeader(), "name"); got != "CLI Ollama" {
|
|
t.Fatalf("CLI provider name = %q, want preserved CLI provider in:\n%s", got, content)
|
|
}
|
|
if got := codexSectionStringValue(content, codexProfileHeaderFor(codexAppProfileName), "model"); got != "llama3.2" {
|
|
t.Fatalf("app profile model = %q, want llama3.2", got)
|
|
}
|
|
if got := codexSectionStringValue(content, codexProviderHeaderFor(codexAppProfileName), "base_url"); got != "http://127.0.0.1:9999/v1/" {
|
|
t.Fatalf("app provider base URL = %q", got)
|
|
}
|
|
assertBackupContains(t, filepath.Join(fileutil.BackupDir(), codexAppIntegrationName, "config.toml.*"), `profile = "default"`)
|
|
}
|
|
|
|
func TestCodexAppConfigureUsesConnectableHostForUnspecifiedBindAddress(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
setTestHome(t, tmpDir)
|
|
t.Setenv("OLLAMA_HOST", "http://0.0.0.0:11434")
|
|
|
|
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil {
|
|
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
|
}
|
|
|
|
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content := string(data)
|
|
if strings.Contains(content, "0.0.0.0") {
|
|
t.Fatalf("config should not write bind-only host, got:\n%s", content)
|
|
}
|
|
if got := codexSectionStringValue(content, codexProfileHeaderFor(codexAppProfileName), "openai_base_url"); got != "http://127.0.0.1:11434/v1/" {
|
|
t.Fatalf("app profile openai_base_url = %q, want connectable loopback URL", got)
|
|
}
|
|
if got := codexSectionStringValue(content, codexProviderHeaderFor(codexAppProfileName), "base_url"); got != "http://127.0.0.1:11434/v1/" {
|
|
t.Fatalf("app provider base_url = %q, want connectable loopback URL", got)
|
|
}
|
|
if got := codexRootStringValue(content, "model_provider"); got != codexAppProfileName {
|
|
t.Fatalf("root model_provider = %q, want %q", got, codexAppProfileName)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppConfigureRejectsMalformedTomlBeforeSideEffects(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
setTestHome(t, tmpDir)
|
|
|
|
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
existing := "profile = \n"
|
|
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"})
|
|
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
|
|
t.Fatalf("ConfigureWithModels error = %v, want invalid TOML", err)
|
|
}
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(data) != existing {
|
|
t.Fatalf("malformed config should be left untouched, got:\n%s", data)
|
|
}
|
|
if _, err := os.Stat(codexAppRestoreStatePath()); !os.IsNotExist(err) {
|
|
t.Fatalf("restore state should not be written before config validation, err=%v", err)
|
|
}
|
|
catalogPath, err := codexAppModelCatalogPath()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(catalogPath); !os.IsNotExist(err) {
|
|
t.Fatalf("model catalog should not be written before config validation, err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppConfigureRejectsMalformedTomlEvenWithExistingRestoreState(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
setTestHome(t, tmpDir)
|
|
|
|
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
existing := "[profiles.ollama-launch\nmodel = \"llama3.2\"\n"
|
|
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restoreState := `{"had_profile":true,"profile":"default","had_model":true,"model":"gpt-5.5","had_model_provider":true,"model_provider":"openai","had_model_catalog_json":false}`
|
|
if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"})
|
|
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
|
|
t.Fatalf("ConfigureWithModels error = %v, want invalid TOML", err)
|
|
}
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(data) != existing {
|
|
t.Fatalf("malformed config should be left untouched, got:\n%s", data)
|
|
}
|
|
stateData, err := os.ReadFile(codexAppRestoreStatePath())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(stateData) != restoreState {
|
|
t.Fatalf("restore state should be left untouched, got:\n%s", stateData)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppCurrentModelRequiresManagedActiveProfile(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
setTestHome(t, tmpDir)
|
|
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
|
|
|
|
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content := "" +
|
|
"profile = \"default\"\n\n" +
|
|
codexProfileHeaderFor(codexAppProfileName) + "\n" +
|
|
"model = \"llama3.2\"\n" +
|
|
fmt.Sprintf("model_provider = %q\n\n", codexAppProfileName) +
|
|
codexProviderHeaderFor(codexAppProfileName) + "\n" +
|
|
"base_url = \"http://127.0.0.1:11434/v1/\"\n"
|
|
if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if got := (&CodexApp{}).CurrentModel(); got != "" {
|
|
t.Fatalf("CurrentModel = %q, want empty when active profile is not managed", got)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppCurrentModelReadsManagedRootConfig(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
setTestHome(t, tmpDir)
|
|
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
|
|
|
|
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content := "" +
|
|
`model = "qwen3:8b"` + "\n" +
|
|
fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n\n" +
|
|
fmt.Sprintf(`model_catalog_json = %q`, mustWriteCodexAppTestCatalog(t, "qwen3:8b")) + "\n\n" +
|
|
codexProfileHeaderFor(codexAppProfileName) + "\n" +
|
|
fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" +
|
|
fmt.Sprintf(`model_catalog_json = %q`, mustCodexAppModelCatalogPath(t)) + "\n\n" +
|
|
codexProviderHeaderFor(codexAppProfileName) + "\n" +
|
|
`base_url = "http://127.0.0.1:11434/v1/"` + "\n"
|
|
if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if got := (&CodexApp{}).CurrentModel(); got != "qwen3:8b" {
|
|
t.Fatalf("CurrentModel = %q, want qwen3:8b", got)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppCurrentModelRequiresHealthyCatalog(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name string
|
|
rootCatalog bool
|
|
profileCatalog bool
|
|
writeCatalog bool
|
|
catalogData string
|
|
}{
|
|
{
|
|
name: "missing catalog reference",
|
|
rootCatalog: false,
|
|
profileCatalog: true,
|
|
writeCatalog: true,
|
|
catalogData: `{"models":[{"slug":"llama3.2"}]}`,
|
|
},
|
|
{
|
|
name: "deleted catalog file",
|
|
rootCatalog: true,
|
|
profileCatalog: true,
|
|
writeCatalog: false,
|
|
catalogData: `{"models":[{"slug":"llama3.2"}]}`,
|
|
},
|
|
{
|
|
name: "missing profile catalog reference",
|
|
rootCatalog: true,
|
|
profileCatalog: false,
|
|
writeCatalog: true,
|
|
catalogData: `{"models":[{"slug":"llama3.2"}]}`,
|
|
},
|
|
{
|
|
name: "corrupt catalog file",
|
|
rootCatalog: true,
|
|
profileCatalog: true,
|
|
writeCatalog: true,
|
|
catalogData: `{"models":`,
|
|
},
|
|
{
|
|
name: "empty catalog",
|
|
rootCatalog: true,
|
|
profileCatalog: true,
|
|
writeCatalog: true,
|
|
catalogData: `{"models":[]}`,
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
setTestHome(t, tmpDir)
|
|
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
|
|
|
|
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
catalogPath := mustCodexAppModelCatalogPath(t)
|
|
if tt.writeCatalog {
|
|
if err := os.WriteFile(catalogPath, []byte(tt.catalogData), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
var rootCatalogLine, profileCatalogLine string
|
|
if tt.rootCatalog {
|
|
rootCatalogLine = fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n"
|
|
}
|
|
if tt.profileCatalog {
|
|
profileCatalogLine = fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n"
|
|
}
|
|
content := "" +
|
|
`model = "llama3.2"` + "\n" +
|
|
fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" +
|
|
rootCatalogLine + "\n" +
|
|
codexProfileHeaderFor(codexAppProfileName) + "\n" +
|
|
fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" +
|
|
profileCatalogLine + "\n" +
|
|
codexProviderHeaderFor(codexAppProfileName) + "\n" +
|
|
`base_url = "http://127.0.0.1:11434/v1/"` + "\n"
|
|
if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if got := (&CodexApp{}).CurrentModel(); got != "" {
|
|
t.Fatalf("CurrentModel = %q, want empty when catalog is unhealthy", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCodexAppConfigurePopulatesCatalogFromTagsAndShow(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
setTestHome(t, tmpDir)
|
|
|
|
showCalls := make(map[string]int)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/tags":
|
|
fmt.Fprint(w, `{"models":[{"name":"gemma4"},{"name":"qwen3:8b"},{"name":"llama3.2"}]}`)
|
|
case "/api/show":
|
|
var req struct {
|
|
Model string `json:"model"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
t.Fatalf("decode show request: %v", err)
|
|
}
|
|
showCalls[req.Model]++
|
|
fmt.Fprintf(w, `{"model_info":{"general.context_length":%d},"capabilities":["vision"]}`, 65536+len(req.Model))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
t.Setenv("OLLAMA_HOST", srv.URL)
|
|
|
|
if err := (&CodexApp{}).ConfigureWithModels("gemma4", []string{"fallback"}); err != nil {
|
|
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
|
}
|
|
|
|
catalogPath, err := codexAppModelCatalogPath()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data, err := os.ReadFile(catalogPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var catalog struct {
|
|
Models []map[string]any `json:"models"`
|
|
}
|
|
if err := json.Unmarshal(data, &catalog); err != nil {
|
|
t.Fatalf("catalog should be valid JSON: %v", err)
|
|
}
|
|
|
|
if got := catalogSlugs(catalog.Models); strings.Join(got, ",") != "gemma4,qwen3:8b,llama3.2" {
|
|
t.Fatalf("catalog slugs = %v, want /api/tags models", got)
|
|
}
|
|
for _, model := range catalog.Models {
|
|
slug, _ := model["slug"].(string)
|
|
if model["display_name"] != slug {
|
|
t.Fatalf("display_name should match slug for %q: %v", slug, model["display_name"])
|
|
}
|
|
if model["visibility"] != "list" {
|
|
t.Fatalf("visibility for %q = %v, want list", slug, model["visibility"])
|
|
}
|
|
if model["default_reasoning_level"] != nil {
|
|
t.Fatalf("default_reasoning_level for %q = %v, want nil", slug, model["default_reasoning_level"])
|
|
}
|
|
levels, ok := model["supported_reasoning_levels"].([]any)
|
|
if !ok || len(levels) != 0 {
|
|
t.Fatalf("supported_reasoning_levels for %q = %v, want empty list", slug, model["supported_reasoning_levels"])
|
|
}
|
|
wantContext := float64(128000)
|
|
wantModalities := []string{"text"}
|
|
wantShowCalls := 0
|
|
if slug == "gemma4" {
|
|
wantContext = float64(65536 + len(slug))
|
|
wantModalities = []string{"text", "image"}
|
|
wantShowCalls = 1
|
|
}
|
|
if model["context_window"] != wantContext {
|
|
t.Fatalf("context_window for %q = %v, want %v", slug, model["context_window"], wantContext)
|
|
}
|
|
if got := catalogInputModalities(model); strings.Join(got, ",") != strings.Join(wantModalities, ",") {
|
|
t.Fatalf("input_modalities for %q = %v, want %v", slug, got, wantModalities)
|
|
}
|
|
if showCalls[slug] != wantShowCalls {
|
|
t.Fatalf("show calls for %q = %d, want %d", slug, showCalls[slug], wantShowCalls)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCodexAppConfigureUpgradesLegacyRestoreState(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
setTestHome(t, tmpDir)
|
|
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999")
|
|
|
|
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
existing := "" +
|
|
`model = "gpt-5.5"` + "\n" +
|
|
`model_provider = "odc-resp-dev"` + "\n\n" +
|
|
"[model_providers.odc-resp-dev]\n" +
|
|
`base_url = "https://example.invalid/v1/"` + "\n"
|
|
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(codexAppRestoreStatePath(), []byte(`{"had_profile":false}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil {
|
|
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
|
}
|
|
|
|
state, err := loadCodexAppRestoreState()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if state.HadProfile {
|
|
t.Fatalf("HadProfile = true, want legacy false")
|
|
}
|
|
if !state.HadModel || state.Model != "gpt-5.5" {
|
|
t.Fatalf("model restore state = (%v, %q), want previous root model", state.HadModel, state.Model)
|
|
}
|
|
if !state.HadModelProvider || state.ModelProvider != "odc-resp-dev" {
|
|
t.Fatalf("model provider restore state = (%v, %q), want previous root provider", state.HadModelProvider, state.ModelProvider)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppRestoreRestoresPreviousProfile(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
setTestHome(t, tmpDir)
|
|
withCodexAppPlatform(t, "darwin")
|
|
|
|
var openCalls int
|
|
withCodexAppProcessHooks(t,
|
|
func() bool { return false },
|
|
func() error { return nil },
|
|
func() error {
|
|
openCalls++
|
|
return nil
|
|
},
|
|
)
|
|
|
|
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
existing := "" +
|
|
"profile = \"default\"\n" +
|
|
"model = \"gpt-5.5\"\n" +
|
|
"model_provider = \"openai\"\n" +
|
|
"model_catalog_json = \"/tmp/original-catalog.json\"\n\n" +
|
|
"[profiles.default]\n" +
|
|
"model = \"gpt-5.5\"\n"
|
|
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
c := &CodexApp{}
|
|
if err := c.ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil {
|
|
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
|
}
|
|
if err := c.Restore(); err != nil {
|
|
t.Fatalf("Restore returned error: %v", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(data), `profile = "default"`) || strings.Contains(string(data), fmt.Sprintf(`profile = %q`, codexAppProfileName)) {
|
|
t.Fatalf("restore should restore previous active profile, got:\n%s", data)
|
|
}
|
|
restored := string(data)
|
|
if strings.Contains(restored, codexProfileHeaderFor(codexAppProfileName)) || strings.Contains(restored, codexProviderHeaderFor(codexAppProfileName)) {
|
|
t.Fatalf("restore should remove owned app sections, got:\n%s", restored)
|
|
}
|
|
for key, want := range map[string]string{
|
|
"profile": "default",
|
|
"model": "gpt-5.5",
|
|
"model_provider": "openai",
|
|
"model_catalog_json": "/tmp/original-catalog.json",
|
|
} {
|
|
if got := codexRootStringValue(restored, key); got != want {
|
|
t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, restored)
|
|
}
|
|
}
|
|
if openCalls != 1 {
|
|
t.Fatalf("open calls = %d, want 1", openCalls)
|
|
}
|
|
if _, err := os.Stat(codexAppRestoreStatePath()); !os.IsNotExist(err) {
|
|
t.Fatalf("restore state should be removed, got err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppRestoreMissingConfigRemovesRestoreState(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
setTestHome(t, tmpDir)
|
|
withCodexAppPlatform(t, "darwin")
|
|
|
|
var openCalls int
|
|
withCodexAppProcessHooks(t,
|
|
func() bool { return false },
|
|
func() error { return nil },
|
|
func() error {
|
|
openCalls++
|
|
return nil
|
|
},
|
|
)
|
|
|
|
if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restoreState := `{"had_profile":true,"profile":"stale","had_model":true,"model":"old","had_model_provider":true,"model_provider":"openai","had_model_catalog_json":false}`
|
|
if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := (&CodexApp{}).Restore(); err != nil {
|
|
t.Fatalf("Restore returned error: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(codexAppRestoreStatePath()); !os.IsNotExist(err) {
|
|
t.Fatalf("restore state should be removed when config is missing, got err=%v", err)
|
|
}
|
|
if openCalls != 1 {
|
|
t.Fatalf("open calls = %d, want 1", openCalls)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppConfigureMissingConfigReplacesStaleRestoreState(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
setTestHome(t, tmpDir)
|
|
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999")
|
|
|
|
if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restoreState := `{"had_profile":true,"profile":"stale","had_model":true,"model":"old","had_model_provider":true,"model_provider":"openai","had_model_catalog_json":false}`
|
|
if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil {
|
|
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
|
}
|
|
|
|
state, err := loadCodexAppRestoreState()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if state.HadProfile || state.HadModel || state.HadModelProvider || state.HadModelCatalogJSON {
|
|
t.Fatalf("restore state = %+v, want empty snapshot when config was missing", state)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppConfigureRefreshesRestoreStateAfterManualProfileSwitch(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
setTestHome(t, tmpDir)
|
|
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999")
|
|
withCodexAppPlatform(t, "darwin")
|
|
|
|
var openCalls int
|
|
withCodexAppProcessHooks(t,
|
|
func() bool { return false },
|
|
func() error { return nil },
|
|
func() error {
|
|
openCalls++
|
|
return nil
|
|
},
|
|
)
|
|
|
|
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
initial := "" +
|
|
`profile = "default"` + "\n" +
|
|
`model = "gpt-5.5"` + "\n" +
|
|
`model_provider = "openai"` + "\n\n" +
|
|
"[profiles.default]\n" +
|
|
`model = "gpt-5.5"` + "\n"
|
|
if err := os.WriteFile(configPath, []byte(initial), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil {
|
|
t.Fatalf("first ConfigureWithModels returned error: %v", err)
|
|
}
|
|
|
|
manual := "" +
|
|
`profile = "manual"` + "\n" +
|
|
`model = "manual-model"` + "\n" +
|
|
`model_provider = "openai"` + "\n\n" +
|
|
"[profiles.manual]\n" +
|
|
`model = "manual-model"` + "\n"
|
|
if err := os.WriteFile(configPath, []byte(manual), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := (&CodexApp{}).ConfigureWithModels("qwen3:8b", []string{"qwen3:8b"}); err != nil {
|
|
t.Fatalf("second ConfigureWithModels returned error: %v", err)
|
|
}
|
|
if err := (&CodexApp{}).Restore(); err != nil {
|
|
t.Fatalf("Restore returned error: %v", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restored := string(data)
|
|
for key, want := range map[string]string{
|
|
"profile": "manual",
|
|
"model": "manual-model",
|
|
"model_provider": "openai",
|
|
} {
|
|
if got := codexRootStringValue(restored, key); got != want {
|
|
t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, restored)
|
|
}
|
|
}
|
|
if openCalls != 1 {
|
|
t.Fatalf("open calls = %d, want 1", openCalls)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppRestoreRejectsMalformedTomlWithoutWriting(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
setTestHome(t, tmpDir)
|
|
withCodexAppPlatform(t, "darwin")
|
|
|
|
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
existing := "model = \"unterminated\n"
|
|
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(codexAppRestoreStatePath(), []byte(`{"had_profile":false,"had_model":false,"had_model_provider":false,"had_model_catalog_json":false}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err := (&CodexApp{}).Restore()
|
|
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
|
|
t.Fatalf("Restore error = %v, want invalid TOML", err)
|
|
}
|
|
catalogPath, pathErr := codexAppModelCatalogPath()
|
|
if pathErr != nil {
|
|
t.Fatal(pathErr)
|
|
}
|
|
for _, want := range []string{
|
|
"Restore did not complete",
|
|
"Codex config: " + configPath,
|
|
"Restore state: " + codexAppRestoreStatePath(),
|
|
"Model catalog: " + catalogPath,
|
|
"Backups: " + filepath.Join(fileutil.BackupDir(), codexAppIntegrationName),
|
|
} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Fatalf("Restore error missing %q:\n%v", want, err)
|
|
}
|
|
}
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(data) != existing {
|
|
t.Fatalf("malformed config should be left untouched, got:\n%s", data)
|
|
}
|
|
if _, err := os.Stat(codexAppRestoreStatePath()); err != nil {
|
|
t.Fatalf("restore state should remain after failed restore: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppRestoreWithoutStateRemovesManagedRootModel(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
setTestHome(t, tmpDir)
|
|
withCodexAppPlatform(t, "darwin")
|
|
|
|
var openCalls int
|
|
withCodexAppProcessHooks(t,
|
|
func() bool { return false },
|
|
func() error { return nil },
|
|
func() error {
|
|
openCalls++
|
|
return nil
|
|
},
|
|
)
|
|
|
|
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
catalogPath, err := codexAppModelCatalogPath()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
existing := "" +
|
|
fmt.Sprintf(`profile = %q`, codexAppProfileName) + "\n" +
|
|
`model = "llama3.2"` + "\n" +
|
|
fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" +
|
|
fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n\n" +
|
|
codexProfileHeaderFor(codexAppProfileName) + "\n" +
|
|
`model = "llama3.2"` + "\n" +
|
|
fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n\n" +
|
|
codexProviderHeaderFor(codexAppProfileName) + "\n" +
|
|
`base_url = "http://127.0.0.1:11434/v1/"` + "\n"
|
|
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(catalogPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(catalogPath, []byte(`{"models":[]}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := (&CodexApp{}).Restore(); err != nil {
|
|
t.Fatalf("Restore returned error: %v", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content := string(data)
|
|
for _, key := range []string{"profile", "model", "model_provider", "model_catalog_json"} {
|
|
if got, ok := codexRootStringValueOK(content, key); ok {
|
|
t.Fatalf("root %s should be removed, got %q in:\n%s", key, got, content)
|
|
}
|
|
}
|
|
if strings.Contains(content, codexProfileHeaderFor(codexAppProfileName)) || strings.Contains(content, codexProviderHeaderFor(codexAppProfileName)) {
|
|
t.Fatalf("owned app sections should be removed, got:\n%s", content)
|
|
}
|
|
if _, err := os.Stat(catalogPath); !os.IsNotExist(err) {
|
|
t.Fatalf("owned catalog should be removed when unused, err=%v", err)
|
|
}
|
|
if openCalls != 1 {
|
|
t.Fatalf("open calls = %d, want 1", openCalls)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppRestoreDoesNotStompUserChangedRootConfig(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
setTestHome(t, tmpDir)
|
|
withCodexAppPlatform(t, "darwin")
|
|
|
|
var openCalls int
|
|
withCodexAppProcessHooks(t,
|
|
func() bool { return false },
|
|
func() error { return nil },
|
|
func() error {
|
|
openCalls++
|
|
return nil
|
|
},
|
|
)
|
|
|
|
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
catalogPath, err := codexAppModelCatalogPath()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
existing := "" +
|
|
`profile = "manual"` + "\n" +
|
|
`model = "gpt-5.5"` + "\n" +
|
|
`model_provider = "openai"` + "\n\n" +
|
|
codexProfileHeaderFor(codexAppProfileName) + "\n" +
|
|
`model = "llama3.2"` + "\n" +
|
|
fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n\n" +
|
|
codexProviderHeaderFor(codexAppProfileName) + "\n" +
|
|
`base_url = "http://127.0.0.1:11434/v1/"` + "\n\n" +
|
|
"[profiles.manual]\n" +
|
|
`model = "gpt-5.5"` + "\n"
|
|
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(catalogPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(catalogPath, []byte(`{"models":[]}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restoreState := `{"had_profile":true,"profile":"default","had_model":true,"model":"old","had_model_provider":true,"model_provider":"old-provider","had_model_catalog_json":false}`
|
|
if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := (&CodexApp{}).Restore(); err != nil {
|
|
t.Fatalf("Restore returned error: %v", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content := string(data)
|
|
for key, want := range map[string]string{
|
|
"profile": "manual",
|
|
"model": "gpt-5.5",
|
|
"model_provider": "openai",
|
|
} {
|
|
if got := codexRootStringValue(content, key); got != want {
|
|
t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, content)
|
|
}
|
|
}
|
|
if strings.Contains(content, codexProfileHeaderFor(codexAppProfileName)) || strings.Contains(content, codexProviderHeaderFor(codexAppProfileName)) {
|
|
t.Fatalf("owned app sections should be removed when no longer active, got:\n%s", content)
|
|
}
|
|
if _, err := os.Stat(catalogPath); !os.IsNotExist(err) {
|
|
t.Fatalf("owned catalog should be removed when unused, err=%v", err)
|
|
}
|
|
if openCalls != 1 {
|
|
t.Fatalf("open calls = %d, want 1", openCalls)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppRestoreDoesNotTreatCLIProfileAsOwned(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
setTestHome(t, tmpDir)
|
|
withCodexAppPlatform(t, "darwin")
|
|
|
|
withCodexAppProcessHooks(t,
|
|
func() bool { return false },
|
|
func() error { return nil },
|
|
func() error { return nil },
|
|
)
|
|
|
|
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
existing := "" +
|
|
`profile = "ollama-launch"` + "\n" +
|
|
`model = "cli-model"` + "\n" +
|
|
`model_provider = "ollama-launch"` + "\n\n" +
|
|
"[profiles.ollama-launch]\n" +
|
|
`model = "cli-model"` + "\n" +
|
|
`openai_base_url = "http://cli.invalid/v1/"` + "\n" +
|
|
`model_provider = "ollama-launch"` + "\n\n" +
|
|
"[model_providers.ollama-launch]\n" +
|
|
`name = "CLI Ollama"` + "\n" +
|
|
`base_url = "http://cli.invalid/v1/"` + "\n" +
|
|
`wire_api = "responses"` + "\n"
|
|
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restoreState := `{"had_profile":true,"profile":"default","had_model":true,"model":"gpt-5.5","had_model_provider":true,"model_provider":"openai","had_model_catalog_json":false}`
|
|
if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := (&CodexApp{}).Restore(); err != nil {
|
|
t.Fatalf("Restore returned error: %v", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(data) != existing {
|
|
t.Fatalf("CLI Codex profile should be left untouched, got:\n%s", data)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppRunRestartsRunningAppWhenConfirmed(t *testing.T) {
|
|
withCodexAppPlatform(t, "darwin")
|
|
restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
|
|
defer restoreConfirm()
|
|
|
|
running := true
|
|
var quitCalls, openCalls int
|
|
withCodexAppProcessHooks(t,
|
|
func() bool { return running },
|
|
func() error {
|
|
quitCalls++
|
|
running = false
|
|
return nil
|
|
},
|
|
func() error {
|
|
openCalls++
|
|
return nil
|
|
},
|
|
)
|
|
|
|
if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil {
|
|
t.Fatalf("Run returned error: %v", err)
|
|
}
|
|
if quitCalls != 1 || openCalls != 1 {
|
|
t.Fatalf("quit/open calls = %d/%d, want 1/1", quitCalls, openCalls)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppRunWaitsForGracefulExitBeforeReopening(t *testing.T) {
|
|
withCodexAppPlatform(t, "darwin")
|
|
restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
|
|
defer restoreConfirm()
|
|
|
|
oldSleep := codexAppSleep
|
|
t.Cleanup(func() {
|
|
codexAppSleep = oldSleep
|
|
})
|
|
|
|
running := true
|
|
var quitCalls, openCalls, sleepCalls int
|
|
codexAppSleep = func(time.Duration) {
|
|
sleepCalls++
|
|
if sleepCalls == 2 {
|
|
running = false
|
|
}
|
|
}
|
|
withCodexAppProcessHooks(t,
|
|
func() bool { return running },
|
|
func() error {
|
|
quitCalls++
|
|
return nil
|
|
},
|
|
func() error {
|
|
openCalls++
|
|
return nil
|
|
},
|
|
)
|
|
|
|
if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil {
|
|
t.Fatalf("Run returned error: %v", err)
|
|
}
|
|
if quitCalls != 1 || openCalls != 1 {
|
|
t.Fatalf("quit/open calls = %d/%d, want 1/1", quitCalls, openCalls)
|
|
}
|
|
if sleepCalls == 0 {
|
|
t.Fatal("expected restart to wait for Codex to exit before reopening")
|
|
}
|
|
}
|
|
|
|
func TestCodexAppRunForceStopsMacAfterGracefulTimeout(t *testing.T) {
|
|
withCodexAppPlatform(t, "darwin")
|
|
restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
|
|
defer restoreConfirm()
|
|
|
|
running := true
|
|
calls := make([]string, 0)
|
|
withCodexAppProcessHooks(t,
|
|
func() bool { return running },
|
|
func() error {
|
|
calls = append(calls, "quit")
|
|
return nil
|
|
},
|
|
func() error {
|
|
calls = append(calls, "open")
|
|
return nil
|
|
},
|
|
)
|
|
codexAppExitTimeout = 0
|
|
codexAppForceQuit = func() error {
|
|
calls = append(calls, "force")
|
|
running = false
|
|
return nil
|
|
}
|
|
|
|
if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil {
|
|
t.Fatalf("Run returned error: %v", err)
|
|
}
|
|
want := []string{"quit", "force", "open"}
|
|
if strings.Join(calls, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("calls = %v, want %v", calls, want)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppRunReturnsMacForceStopError(t *testing.T) {
|
|
withCodexAppPlatform(t, "darwin")
|
|
restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
|
|
defer restoreConfirm()
|
|
|
|
withCodexAppProcessHooks(t,
|
|
func() bool { return true },
|
|
func() error { return nil },
|
|
func() error {
|
|
t.Fatal("app should not reopen when force stop fails")
|
|
return nil
|
|
},
|
|
)
|
|
codexAppExitTimeout = 0
|
|
codexAppForceQuit = func() error {
|
|
return fmt.Errorf("operation not permitted")
|
|
}
|
|
|
|
err := (&CodexApp{}).Run("qwen3.5", nil)
|
|
if err == nil || !strings.Contains(err.Error(), "force stop Codex") || !strings.Contains(err.Error(), "operation not permitted") {
|
|
t.Fatalf("Run error = %v, want force stop failure", err)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppRunOpensOnWindowsWhenNotRunning(t *testing.T) {
|
|
withCodexAppPlatform(t, "windows")
|
|
|
|
var openCalls int
|
|
withCodexAppProcessHooks(t,
|
|
func() bool { return false },
|
|
func() error { return nil },
|
|
func() error {
|
|
openCalls++
|
|
return nil
|
|
},
|
|
)
|
|
|
|
if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil {
|
|
t.Fatalf("Run returned error: %v", err)
|
|
}
|
|
if openCalls != 1 {
|
|
t.Fatalf("open calls = %d, want 1", openCalls)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppRunRestartsWindowsStartAppID(t *testing.T) {
|
|
withCodexAppPlatform(t, "windows")
|
|
restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
|
|
defer restoreConfirm()
|
|
|
|
running := true
|
|
var quitCalls int
|
|
withCodexAppProcessHooks(t,
|
|
func() bool { return running },
|
|
func() error {
|
|
quitCalls++
|
|
running = false
|
|
return nil
|
|
},
|
|
func() error {
|
|
t.Fatal("open app fallback should not be used")
|
|
return nil
|
|
},
|
|
)
|
|
|
|
codexAppStartID = func() string { return "OpenAI.Codex_2p2nqsd0c76g0!App" }
|
|
codexAppRunPath = func() string {
|
|
return `C:\Program Files\WindowsApps\OpenAI.Codex_26.429.8261.0_x64__2p2nqsd0c76g0\app\Codex.exe`
|
|
}
|
|
var openedStartID, openedPath string
|
|
codexAppOpenStart = func(appID string) error {
|
|
openedStartID = appID
|
|
return nil
|
|
}
|
|
codexAppOpenPath = func(path string) error {
|
|
openedPath = path
|
|
return nil
|
|
}
|
|
|
|
if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil {
|
|
t.Fatalf("Run returned error: %v", err)
|
|
}
|
|
if quitCalls != 1 {
|
|
t.Fatalf("quit calls = %d, want 1", quitCalls)
|
|
}
|
|
if openedStartID != "OpenAI.Codex_2p2nqsd0c76g0!App" {
|
|
t.Fatalf("opened Start AppID = %q", openedStartID)
|
|
}
|
|
if openedPath != "" {
|
|
t.Fatalf("opened path = %q, want Start AppID path only", openedPath)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppRunForceStopsWindowsBackgroundProcessesBeforeReopening(t *testing.T) {
|
|
withCodexAppPlatform(t, "windows")
|
|
restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
|
|
defer restoreConfirm()
|
|
|
|
windowOpen := true
|
|
running := true
|
|
calls := make([]string, 0)
|
|
withCodexAppProcessHooks(t,
|
|
func() bool { return running },
|
|
func() error {
|
|
calls = append(calls, "quit")
|
|
windowOpen = false
|
|
return nil
|
|
},
|
|
func() error {
|
|
t.Fatal("open app fallback should not be used")
|
|
return nil
|
|
},
|
|
)
|
|
codexAppHasWindow = func() bool { return windowOpen }
|
|
codexAppForceQuit = func() error {
|
|
calls = append(calls, "force")
|
|
running = false
|
|
return nil
|
|
}
|
|
codexAppStartID = func() string { return "OpenAI.Codex_2p2nqsd0c76g0!App" }
|
|
codexAppOpenStart = func(appID string) error {
|
|
calls = append(calls, "open:"+appID)
|
|
return nil
|
|
}
|
|
|
|
if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil {
|
|
t.Fatalf("Run returned error: %v", err)
|
|
}
|
|
want := []string{"quit", "force", "open:OpenAI.Codex_2p2nqsd0c76g0!App"}
|
|
if strings.Join(calls, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("calls = %v, want %v", calls, want)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppRunReturnsWindowsForceStopError(t *testing.T) {
|
|
withCodexAppPlatform(t, "windows")
|
|
restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
|
|
defer restoreConfirm()
|
|
|
|
windowOpen := true
|
|
withCodexAppProcessHooks(t,
|
|
func() bool { return true },
|
|
func() error {
|
|
windowOpen = false
|
|
return nil
|
|
},
|
|
func() error {
|
|
t.Fatal("open app fallback should not be used")
|
|
return nil
|
|
},
|
|
)
|
|
codexAppHasWindow = func() bool { return windowOpen }
|
|
codexAppForceQuit = func() error {
|
|
return fmt.Errorf("access denied")
|
|
}
|
|
codexAppOpenStart = func(string) error {
|
|
t.Fatal("app should not reopen when force stop fails")
|
|
return nil
|
|
}
|
|
|
|
err := (&CodexApp{}).Run("qwen3.5", nil)
|
|
if err == nil || !strings.Contains(err.Error(), "force stop Codex") || !strings.Contains(err.Error(), "access denied") {
|
|
t.Fatalf("Run error = %v, want force stop failure", err)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppRunRejectsExtraArgs(t *testing.T) {
|
|
withCodexAppPlatform(t, "darwin")
|
|
err := (&CodexApp{}).Run("qwen3.5", []string{"--foo"})
|
|
if err == nil || !strings.Contains(err.Error(), "does not accept extra arguments") {
|
|
t.Fatalf("Run error = %v, want extra args rejection", err)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppProcessMatchesMainAndAppServer(t *testing.T) {
|
|
for _, command := range []string{
|
|
"/Applications/Codex.app/Contents/MacOS/Codex",
|
|
"/Applications/Codex.app/Contents/Resources/codex app-server --analytics-default-enabled",
|
|
`C:\Users\parth\AppData\Local\Programs\Codex\Codex.exe`,
|
|
`"C:\Users\parth\AppData\Local\Codex\app-26.429.30905\resources\codex.exe" app-server --analytics-default-enabled`,
|
|
`"C:\Users\parth\AppData\Local\openai-codex-electron\resources\codex.exe" "app-server"`,
|
|
} {
|
|
if !codexAppProcessMatches(command) {
|
|
t.Fatalf("expected command to match Codex App process: %s", command)
|
|
}
|
|
}
|
|
|
|
for _, command := range []string{
|
|
"/Applications/Codex.app/Contents/Frameworks/Codex Helper.app/Contents/MacOS/Codex Helper",
|
|
"/Applications/Codex.app/Contents/Frameworks/Electron Framework.framework/Helpers/chrome_crashpad_handler",
|
|
`"C:\Program Files\WindowsApps\OpenAI.Codex_26.429.8261.0_x64__2p2nqsd0c76g0\app\Codex.exe" --type=renderer --user-data-dir="C:\Users\parth\AppData\Roaming\Codex"`,
|
|
`"C:\Program Files\WindowsApps\OpenAI.Codex_26.429.8261.0_x64__2p2nqsd0c76g0\app\Codex.exe" --type=crashpad-handler`,
|
|
} {
|
|
if codexAppProcessMatches(command) {
|
|
t.Fatalf("expected helper command not to match Codex App process: %s", command)
|
|
}
|
|
}
|
|
}
|
|
|
|
func catalogSlugs(models []map[string]any) []string {
|
|
slugs := make([]string, 0, len(models))
|
|
for _, model := range models {
|
|
if slug, _ := model["slug"].(string); slug != "" {
|
|
slugs = append(slugs, slug)
|
|
}
|
|
}
|
|
return slugs
|
|
}
|
|
|
|
func catalogInputModalities(entry map[string]any) []string {
|
|
raw, _ := entry["input_modalities"].([]any)
|
|
modalities := make([]string, 0, len(raw))
|
|
for _, item := range raw {
|
|
if modality, _ := item.(string); modality != "" {
|
|
modalities = append(modalities, modality)
|
|
}
|
|
}
|
|
return modalities
|
|
}
|
|
|
|
func mustCodexAppModelCatalogPath(t *testing.T) string {
|
|
t.Helper()
|
|
catalogPath, err := codexAppModelCatalogPath()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return catalogPath
|
|
}
|
|
|
|
func mustWriteCodexAppTestCatalog(t *testing.T, slugs ...string) string {
|
|
t.Helper()
|
|
catalogPath := mustCodexAppModelCatalogPath(t)
|
|
if err := os.MkdirAll(filepath.Dir(catalogPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
models := make([]map[string]string, 0, len(slugs))
|
|
for _, slug := range slugs {
|
|
models = append(models, map[string]string{"slug": slug})
|
|
}
|
|
data, err := json.Marshal(map[string]any{"models": models})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(catalogPath, data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return catalogPath
|
|
}
|