Files
ollama/cmd/launch/launch_test.go
Bruce MacDonald 5d0000634c cmd/launch: check for both npm and git before installing OpenClaw (#14888)
The OpenClaw installer requires git in addition to npm. Update the
dependency check to detect both and provide specific install guidance
for whichever dependencies are missing.
2026-03-17 18:20:05 -07:00

1499 lines
44 KiB
Go

package launch
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
"github.com/ollama/ollama/cmd/config"
)
type launcherEditorRunner struct {
paths []string
edited [][]string
ranModel string
}
func (r *launcherEditorRunner) Run(model string, args []string) error {
r.ranModel = model
return nil
}
func (r *launcherEditorRunner) String() string { return "LauncherEditor" }
func (r *launcherEditorRunner) Paths() []string { return r.paths }
func (r *launcherEditorRunner) Edit(models []string) error {
r.edited = append(r.edited, append([]string(nil), models...))
return nil
}
func (r *launcherEditorRunner) Models() []string { return nil }
type launcherSingleRunner struct {
ranModel string
}
func (r *launcherSingleRunner) Run(model string, args []string) error {
r.ranModel = model
return nil
}
func (r *launcherSingleRunner) String() string { return "StubSingle" }
func setLaunchTestHome(t *testing.T, dir string) {
t.Helper()
t.Setenv("HOME", dir)
t.Setenv("TMPDIR", dir)
t.Setenv("USERPROFILE", dir)
}
func writeFakeBinary(t *testing.T, dir, name string) {
t.Helper()
path := filepath.Join(dir, name)
data := []byte("#!/bin/sh\nexit 0\n")
if runtime.GOOS == "windows" {
path += ".cmd"
data = []byte("@echo off\r\nexit /b 0\r\n")
}
if err := os.WriteFile(path, data, 0o755); err != nil {
t.Fatalf("failed to write fake binary: %v", err)
}
}
func withIntegrationOverride(t *testing.T, name string, runner Runner) {
t.Helper()
restore := OverrideIntegration(name, runner)
t.Cleanup(restore)
}
func withInteractiveSession(t *testing.T, interactive bool) {
t.Helper()
old := isInteractiveSession
isInteractiveSession = func() bool { return interactive }
t.Cleanup(func() {
isInteractiveSession = old
})
}
func withLauncherHooks(t *testing.T) {
t.Helper()
oldSingle := DefaultSingleSelector
oldMulti := DefaultMultiSelector
oldConfirm := DefaultConfirmPrompt
oldSignIn := DefaultSignIn
t.Cleanup(func() {
DefaultSingleSelector = oldSingle
DefaultMultiSelector = oldMulti
DefaultConfirmPrompt = oldConfirm
DefaultSignIn = oldSignIn
})
}
func TestDefaultLaunchPolicy(t *testing.T) {
tests := []struct {
name string
interactive bool
yes bool
want LaunchPolicy
}{
{
name: "interactive default prompts and prompt-pull",
interactive: true,
yes: false,
want: LaunchPolicy{Confirm: LaunchConfirmPrompt, MissingModel: LaunchMissingModelPromptToPull},
},
{
name: "headless without yes requires yes and fail-missing",
interactive: false,
yes: false,
want: LaunchPolicy{Confirm: LaunchConfirmRequireYes, MissingModel: LaunchMissingModelFail},
},
{
name: "interactive yes auto-approves and auto-pulls",
interactive: true,
yes: true,
want: LaunchPolicy{Confirm: LaunchConfirmAutoApprove, MissingModel: LaunchMissingModelAutoPull},
},
{
name: "headless yes auto-approves and auto-pulls",
interactive: false,
yes: true,
want: LaunchPolicy{Confirm: LaunchConfirmAutoApprove, MissingModel: LaunchMissingModelAutoPull},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := defaultLaunchPolicy(tt.interactive, tt.yes)
if got != tt.want {
t.Fatalf("defaultLaunchPolicy(%v, %v) = %+v, want %+v", tt.interactive, tt.yes, got, tt.want)
}
})
}
}
func TestBuildLauncherState_InstalledAndCloudDisabled(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
binDir := t.TempDir()
writeFakeBinary(t, binDir, "opencode")
t.Setenv("PATH", binDir)
if err := config.SetLastModel("glm-5:cloud"); err != nil {
t.Fatalf("failed to save last model: %v", err)
}
if err := config.SaveIntegration("claude", []string{"glm-5:cloud"}); err != nil {
t.Fatalf("failed to save claude config: %v", err)
}
if err := config.SaveIntegration("opencode", []string{"glm-5:cloud", "llama3.2"}); err != nil {
t.Fatalf("failed to save opencode config: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`)
case "/api/status":
fmt.Fprint(w, `{"cloud":{"disabled":true,"source":"config"}}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
state, err := BuildLauncherState(context.Background())
if err != nil {
t.Fatalf("BuildLauncherState returned error: %v", err)
}
if !state.Integrations["opencode"].Installed {
t.Fatal("expected opencode to be marked installed")
}
if state.Integrations["claude"].Installed {
t.Fatal("expected claude to be marked not installed")
}
if state.RunModelUsable {
t.Fatal("expected saved cloud run model to be unusable when cloud is disabled")
}
if state.Integrations["claude"].ModelUsable {
t.Fatal("expected claude cloud config to be unusable when cloud is disabled")
}
if !state.Integrations["opencode"].ModelUsable {
t.Fatal("expected editor config with a remaining local model to stay usable")
}
if state.Integrations["opencode"].CurrentModel != "llama3.2" {
t.Fatalf("expected editor current model to fall back to remaining local model, got %q", state.Integrations["opencode"].CurrentModel)
}
}
func TestBuildLauncherState_MigratesLegacyOpenclawAliasConfig(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
if err := config.SaveIntegration("clawdbot", []string{"llama3.2"}); err != nil {
t.Fatalf("failed to seed legacy alias config: %v", err)
}
if err := config.SaveAliases("clawdbot", map[string]string{"primary": "llama3.2"}); err != nil {
t.Fatalf("failed to seed legacy alias map: %v", err)
}
if err := config.MarkIntegrationOnboarded("clawdbot"); err != nil {
t.Fatalf("failed to seed legacy onboarding state: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
state, err := BuildLauncherState(context.Background())
if err != nil {
t.Fatalf("BuildLauncherState returned error: %v", err)
}
if state.Integrations["openclaw"].CurrentModel != "llama3.2" {
t.Fatalf("expected openclaw state to reuse legacy alias config, got %q", state.Integrations["openclaw"].CurrentModel)
}
migrated, err := config.LoadIntegration("openclaw")
if err != nil {
t.Fatalf("expected canonical config to be migrated, got %v", err)
}
if !slices.Equal(migrated.Models, []string{"llama3.2"}) {
t.Fatalf("unexpected migrated models: %v", migrated.Models)
}
if migrated.Aliases["primary"] != "llama3.2" {
t.Fatalf("expected aliases to migrate, got %v", migrated.Aliases)
}
if !migrated.Onboarded {
t.Fatal("expected onboarding state to migrate to canonical openclaw key")
}
}
func TestBuildLauncherState_ToleratesInventoryFailure(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
if err := config.SetLastModel("llama3.2"); err != nil {
t.Fatalf("failed to seed last model: %v", err)
}
if err := config.SaveIntegration("claude", []string{"qwen3:8b"}); err != nil {
t.Fatalf("failed to seed claude config: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, `{"error":"temporary failure"}`)
case "/api/show":
var req apiShowRequest
_ = json.NewDecoder(r.Body).Decode(&req)
fmt.Fprintf(w, `{"model":%q}`, req.Model)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
state, err := BuildLauncherState(context.Background())
if err != nil {
t.Fatalf("BuildLauncherState should tolerate inventory failure, got %v", err)
}
if !state.RunModelUsable {
t.Fatal("expected saved run model to remain usable via show fallback")
}
if state.Integrations["claude"].CurrentModel != "qwen3:8b" {
t.Fatalf("expected saved integration model to remain visible, got %q", state.Integrations["claude"].CurrentModel)
}
if !state.Integrations["claude"].ModelUsable {
t.Fatal("expected saved integration model to remain usable via show fallback")
}
}
func TestResolveRunModel_UsesSavedModelWithoutSelector(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
if err := config.SetLastModel("llama3.2"); err != nil {
t.Fatalf("failed to save last model: %v", err)
}
selectorCalled := false
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
selectorCalled = true
return "", nil
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`)
case "/api/show":
fmt.Fprint(w, `{"model":"llama3.2"}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
model, err := ResolveRunModel(context.Background(), RunModelRequest{})
if err != nil {
t.Fatalf("ResolveRunModel returned error: %v", err)
}
if model != "llama3.2" {
t.Fatalf("expected saved model, got %q", model)
}
if selectorCalled {
t.Fatal("selector should not be called when saved model is usable")
}
}
func TestResolveRunModel_HeadlessYesAutoPicksLastModel(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
withInteractiveSession(t, false)
if err := config.SetLastModel("missing-model"); err != nil {
t.Fatalf("failed to save last model: %v", err)
}
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
t.Fatal("selector should not be called in headless --yes mode")
return "", nil
}
restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
defer restoreConfirm()
pullCalled := false
modelPulled := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`)
case "/api/show":
var req apiShowRequest
_ = json.NewDecoder(r.Body).Decode(&req)
if req.Model == "missing-model" && !modelPulled {
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `{"error":"model not found"}`)
return
}
fmt.Fprintf(w, `{"model":%q}`, req.Model)
case "/api/pull":
pullCalled = true
modelPulled = true
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"status":"success"}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
var model string
stderr := captureStderr(t, func() {
var err error
model, err = ResolveRunModel(context.Background(), RunModelRequest{})
if err != nil {
t.Fatalf("ResolveRunModel returned error: %v", err)
}
})
if model != "missing-model" {
t.Fatalf("expected saved last model to be selected, got %q", model)
}
if !pullCalled {
t.Fatal("expected missing saved model to be auto-pulled in headless --yes mode")
}
if !strings.Contains(stderr, `Headless mode: auto-selected last used model "missing-model"`) {
t.Fatalf("expected headless auto-pick message in stderr, got %q", stderr)
}
}
func TestResolveRunModel_UsesRequestPolicy(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
withInteractiveSession(t, false)
if err := config.SetLastModel("missing-model"); err != nil {
t.Fatalf("failed to save last model: %v", err)
}
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
t.Fatal("selector should not be called when request policy enables headless auto-pick")
return "", nil
}
pullCalled := false
modelPulled := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`)
case "/api/show":
var req apiShowRequest
_ = json.NewDecoder(r.Body).Decode(&req)
if req.Model == "missing-model" && !modelPulled {
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `{"error":"model not found"}`)
return
}
fmt.Fprintf(w, `{"model":%q}`, req.Model)
case "/api/pull":
pullCalled = true
modelPulled = true
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"status":"success"}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
reqPolicy := LaunchPolicy{
Confirm: LaunchConfirmAutoApprove,
MissingModel: LaunchMissingModelAutoPull,
}
model, err := ResolveRunModel(context.Background(), RunModelRequest{Policy: &reqPolicy})
if err != nil {
t.Fatalf("ResolveRunModel returned error: %v", err)
}
if model != "missing-model" {
t.Fatalf("expected saved last model to be selected, got %q", model)
}
if !pullCalled {
t.Fatal("expected missing saved model to be auto-pulled when request policy enables auto-pull")
}
}
func TestResolveRunModel_ForcePickerAlwaysUsesSelector(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
if err := config.SetLastModel("llama3.2"); err != nil {
t.Fatalf("failed to save last model: %v", err)
}
var selectorCalls int
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
selectorCalls++
if current != "llama3.2" {
t.Fatalf("expected current selection to be last model, got %q", current)
}
return "qwen3:8b", nil
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"llama3.2"},{"name":"qwen3:8b"}]}`)
case "/api/show":
fmt.Fprint(w, `{"model":"qwen3:8b"}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
model, err := ResolveRunModel(context.Background(), RunModelRequest{ForcePicker: true})
if err != nil {
t.Fatalf("ResolveRunModel returned error: %v", err)
}
if selectorCalls != 1 {
t.Fatalf("expected selector to be called once, got %d", selectorCalls)
}
if model != "qwen3:8b" {
t.Fatalf("expected forced selection to win, got %q", model)
}
if got := config.LastModel(); got != "qwen3:8b" {
t.Fatalf("expected last model to be updated, got %q", got)
}
}
func TestResolveRunModel_ForcePicker_DoesNotReorderByLastModel(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
if err := config.SetLastModel("qwen3.5"); err != nil {
t.Fatalf("failed to save last model: %v", err)
}
var gotNames []string
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
if current != "qwen3.5" {
t.Fatalf("expected current selection to be last model, got %q", current)
}
gotNames = make([]string, 0, len(items))
for _, item := range items {
gotNames = append(gotNames, item.Name)
}
return "qwen3.5", nil
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"qwen3.5"},{"name":"glm-4.7-flash"}]}`)
case "/api/show":
fmt.Fprint(w, `{"model":"qwen3.5"}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
_, err := ResolveRunModel(context.Background(), RunModelRequest{ForcePicker: true})
if err != nil {
t.Fatalf("ResolveRunModel returned error: %v", err)
}
if len(gotNames) == 0 {
t.Fatal("expected selector to receive model items")
}
glmIdx := slices.Index(gotNames, "glm-4.7-flash")
qwenIdx := slices.Index(gotNames, "qwen3.5")
if glmIdx == -1 || qwenIdx == -1 {
t.Fatalf("expected recommended local models in selector items, got %v", gotNames)
}
if qwenIdx < glmIdx {
t.Fatalf("expected list order to stay stable and not float last model to top, got %v", gotNames)
}
}
func TestResolveRunModel_UsesSignInHookForCloudModel(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
return "glm-5:cloud", nil
}
signInCalled := false
DefaultSignIn = func(modelName, signInURL string) (string, error) {
signInCalled = true
if modelName != "glm-5:cloud" {
t.Fatalf("unexpected model passed to sign-in: %q", modelName)
}
return "test-user", nil
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
fmt.Fprint(w, `{"models":[]}`)
case "/api/status":
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `{"error":"not found"}`)
case "/api/show":
fmt.Fprint(w, `{"remote_model":"glm-5"}`)
case "/api/me":
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprint(w, `{"error":"unauthorized","signin_url":"https://example.com/signin"}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
model, err := ResolveRunModel(context.Background(), RunModelRequest{ForcePicker: true})
if err != nil {
t.Fatalf("ResolveRunModel returned error: %v", err)
}
if model != "glm-5:cloud" {
t.Fatalf("expected selected cloud model, got %q", model)
}
if !signInCalled {
t.Fatal("expected sign-in hook to be used for cloud model")
}
}
func TestLaunchIntegration_EditorForceConfigure(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
binDir := t.TempDir()
writeFakeBinary(t, binDir, "droid")
t.Setenv("PATH", binDir)
editor := &launcherEditorRunner{paths: []string{"/tmp/settings.json"}}
withIntegrationOverride(t, "droid", editor)
var multiCalled bool
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
multiCalled = true
return []string{"llama3.2", "qwen3:8b"}, nil
}
var proceedPrompt bool
DefaultConfirmPrompt = func(prompt string) (bool, error) {
if prompt == "Proceed?" {
proceedPrompt = true
}
return true, nil
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"llama3.2"},{"name":"qwen3:8b"}]}`)
case "/api/show":
var req apiShowRequest
_ = json.NewDecoder(r.Body).Decode(&req)
fmt.Fprintf(w, `{"model":%q}`, req.Model)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
Name: "droid",
ForceConfigure: true,
}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
if !multiCalled {
t.Fatal("expected multi selector to be used for forced editor configure")
}
if !proceedPrompt {
t.Fatal("expected backup warning confirmation before edit")
}
if diff := compareStringSlices(editor.edited, [][]string{{"llama3.2", "qwen3:8b"}}); diff != "" {
t.Fatalf("unexpected edited models (-want +got):\n%s", diff)
}
if editor.ranModel != "llama3.2" {
t.Fatalf("expected launch to use first selected model, got %q", editor.ranModel)
}
saved, err := config.LoadIntegration("droid")
if err != nil {
t.Fatalf("failed to reload saved config: %v", err)
}
if diff := compareStrings(saved.Models, []string{"llama3.2", "qwen3:8b"}); diff != "" {
t.Fatalf("unexpected saved models (-want +got):\n%s", diff)
}
}
func TestLaunchIntegration_EditorForceConfigure_DoesNotFloatCheckedModelsInPicker(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
binDir := t.TempDir()
writeFakeBinary(t, binDir, "droid")
t.Setenv("PATH", binDir)
editor := &launcherEditorRunner{}
withIntegrationOverride(t, "droid", editor)
if err := config.SaveIntegration("droid", []string{"qwen3.5:cloud", "qwen3.5"}); err != nil {
t.Fatalf("failed to seed config: %v", err)
}
var gotItems []string
var gotPreChecked []string
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
for _, item := range items {
gotItems = append(gotItems, item.Name)
}
gotPreChecked = append([]string(nil), preChecked...)
return []string{"qwen3.5:cloud", "qwen3.5"}, nil
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"qwen3.5:cloud","remote_model":"qwen3.5"},{"name":"qwen3.5"}]}`)
case "/api/show":
var req apiShowRequest
_ = json.NewDecoder(r.Body).Decode(&req)
if req.Model == "qwen3.5:cloud" {
fmt.Fprint(w, `{"remote_model":"qwen3.5"}`)
return
}
fmt.Fprintf(w, `{"model":%q}`, req.Model)
case "/api/me":
fmt.Fprint(w, `{"name":"test-user"}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
Name: "droid",
ForceConfigure: true,
}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
if len(gotItems) == 0 {
t.Fatal("expected multi selector to receive items")
}
if gotItems[0] != "kimi-k2.5:cloud" {
t.Fatalf("expected stable recommendation order with kimi-k2.5:cloud first, got %v", gotItems)
}
if len(gotPreChecked) < 2 {
t.Fatalf("expected prechecked models to be preserved, got %v", gotPreChecked)
}
if gotPreChecked[0] != "qwen3.5:cloud" {
t.Fatalf("expected saved default to remain first in prechecked, got %v", gotPreChecked)
}
}
func TestLaunchIntegration_EditorModelOverridePreservesExtras(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
binDir := t.TempDir()
writeFakeBinary(t, binDir, "droid")
t.Setenv("PATH", binDir)
editor := &launcherEditorRunner{}
withIntegrationOverride(t, "droid", editor)
if err := config.SaveIntegration("droid", []string{"llama3.2", "mistral"}); err != nil {
t.Fatalf("failed to seed config: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
var req apiShowRequest
_ = json.NewDecoder(r.Body).Decode(&req)
fmt.Fprintf(w, `{"model":%q}`, req.Model)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
Name: "droid",
ModelOverride: "qwen3:8b",
}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
want := []string{"qwen3:8b", "llama3.2", "mistral"}
saved, err := config.LoadIntegration("droid")
if err != nil {
t.Fatalf("failed to reload saved config: %v", err)
}
if diff := compareStrings(saved.Models, want); diff != "" {
t.Fatalf("unexpected saved models (-want +got):\n%s", diff)
}
if diff := compareStringSlices(editor.edited, [][]string{want}); diff != "" {
t.Fatalf("unexpected edited models (-want +got):\n%s", diff)
}
if editor.ranModel != "qwen3:8b" {
t.Fatalf("expected override model to launch first, got %q", editor.ranModel)
}
}
func TestLaunchIntegration_EditorCloudDisabledFallsBackToSelector(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
binDir := t.TempDir()
writeFakeBinary(t, binDir, "droid")
t.Setenv("PATH", binDir)
editor := &launcherEditorRunner{}
withIntegrationOverride(t, "droid", editor)
if err := config.SaveIntegration("droid", []string{"glm-5:cloud"}); err != nil {
t.Fatalf("failed to seed config: %v", err)
}
var multiCalled bool
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
multiCalled = true
return []string{"llama3.2"}, nil
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/status":
fmt.Fprint(w, `{"cloud":{"disabled":true,"source":"config"}}`)
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`)
case "/api/show":
fmt.Fprint(w, `{"model":"llama3.2"}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "droid"}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
if !multiCalled {
t.Fatal("expected editor flow to reopen selector when cloud-only config is unusable")
}
}
func TestLaunchIntegration_ConfiguredEditorLaunchSkipsReconfigure(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
binDir := t.TempDir()
writeFakeBinary(t, binDir, "droid")
t.Setenv("PATH", binDir)
editor := &launcherEditorRunner{paths: []string{"/tmp/settings.json"}}
withIntegrationOverride(t, "droid", editor)
if err := config.SaveIntegration("droid", []string{"llama3.2", "qwen3:8b"}); err != nil {
t.Fatalf("failed to seed config: %v", err)
}
DefaultConfirmPrompt = func(prompt string) (bool, error) {
t.Fatalf("did not expect prompt during a normal editor launch: %s", prompt)
return false, nil
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
var req apiShowRequest
_ = json.NewDecoder(r.Body).Decode(&req)
fmt.Fprintf(w, `{"model":%q}`, req.Model)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "droid"}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
if len(editor.edited) != 0 {
t.Fatalf("expected normal launch to skip editor rewrites, got %v", editor.edited)
}
if editor.ranModel != "llama3.2" {
t.Fatalf("expected launch to use saved primary model, got %q", editor.ranModel)
}
saved, err := config.LoadIntegration("droid")
if err != nil {
t.Fatalf("failed to reload saved config: %v", err)
}
if diff := compareStrings(saved.Models, []string{"llama3.2", "qwen3:8b"}); diff != "" {
t.Fatalf("unexpected saved models (-want +got):\n%s", diff)
}
}
func TestLaunchIntegration_OpenclawPreservesExistingModelList(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
binDir := t.TempDir()
writeFakeBinary(t, binDir, "openclaw")
t.Setenv("PATH", binDir)
editor := &launcherEditorRunner{}
withIntegrationOverride(t, "openclaw", editor)
if err := config.SaveIntegration("openclaw", []string{"llama3.2", "mistral"}); err != nil {
t.Fatalf("failed to seed config: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
var req apiShowRequest
_ = json.NewDecoder(r.Body).Decode(&req)
fmt.Fprintf(w, `{"model":%q}`, req.Model)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "openclaw"}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
if len(editor.edited) != 0 {
t.Fatalf("expected launch to preserve the existing OpenClaw config, got rewrites %v", editor.edited)
}
if editor.ranModel != "llama3.2" {
t.Fatalf("expected launch to use first saved model, got %q", editor.ranModel)
}
saved, err := config.LoadIntegration("openclaw")
if err != nil {
t.Fatalf("failed to reload saved config: %v", err)
}
if diff := compareStrings(saved.Models, []string{"llama3.2", "mistral"}); diff != "" {
t.Fatalf("unexpected saved models (-want +got):\n%s", diff)
}
}
func TestLaunchIntegration_OpenclawInstallsBeforeConfigSideEffects(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
t.Setenv("PATH", t.TempDir())
editor := &launcherEditorRunner{}
withIntegrationOverride(t, "openclaw", editor)
selectorCalled := false
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
selectorCalled = true
return []string{"llama3.2"}, nil
}
err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "openclaw"})
if err == nil {
t.Fatal("expected launch to fail before configuration when OpenClaw is missing")
}
if !strings.Contains(err.Error(), "required dependencies are missing") {
t.Fatalf("expected install prerequisite error, got %v", err)
}
if selectorCalled {
t.Fatal("expected install check to happen before model selection")
}
if len(editor.edited) != 0 {
t.Fatalf("expected no editor writes before install succeeds, got %v", editor.edited)
}
if _, statErr := os.Stat(filepath.Join(tmpDir, ".openclaw", "openclaw.json")); !os.IsNotExist(statErr) {
t.Fatalf("expected no OpenClaw config file to be created, stat err = %v", statErr)
}
}
func TestLaunchIntegration_ConfigureOnlyDoesNotRequireInstalledBinary(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
t.Setenv("PATH", t.TempDir())
editor := &launcherEditorRunner{paths: []string{"/tmp/settings.json"}}
withIntegrationOverride(t, "droid", editor)
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
return []string{"llama3.2"}, nil
}
var prompts []string
DefaultConfirmPrompt = func(prompt string) (bool, error) {
prompts = append(prompts, prompt)
if strings.Contains(prompt, "Launch LauncherEditor now?") {
return false, nil
}
return true, nil
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`)
case "/api/show":
fmt.Fprint(w, `{"model":"llama3.2"}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
Name: "droid",
ForceConfigure: true,
ConfigureOnly: true,
}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
if diff := compareStringSlices(editor.edited, [][]string{{"llama3.2"}}); diff != "" {
t.Fatalf("unexpected edited models (-want +got):\n%s", diff)
}
if editor.ranModel != "" {
t.Fatalf("expected configure-only flow to skip launch, got %q", editor.ranModel)
}
if !slices.Contains(prompts, "Proceed?") {
t.Fatalf("expected editor warning prompt, got %v", prompts)
}
if !slices.Contains(prompts, "Launch LauncherEditor now?") {
t.Fatalf("expected configure-only launch prompt, got %v", prompts)
}
}
func TestLaunchIntegration_ClaudeSavesPrimaryModel(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
binDir := t.TempDir()
writeFakeBinary(t, binDir, "claude")
t.Setenv("PATH", binDir)
var aliasSyncCalled bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
fmt.Fprint(w, `{"models":[]}`)
case "/api/status":
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `{"error":"not found"}`)
case "/api/show":
fmt.Fprint(w, `{"remote_model":"glm-5"}`)
case "/api/me":
fmt.Fprint(w, `{"name":"test-user"}`)
case "/api/experimental/aliases":
aliasSyncCalled = true
t.Fatalf("did not expect alias sync call after removing Claude alias flow")
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
Name: "claude",
ModelOverride: "glm-5:cloud",
}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
saved, err := config.LoadIntegration("claude")
if err != nil {
t.Fatalf("failed to reload saved config: %v", err)
}
if diff := compareStrings(saved.Models, []string{"glm-5:cloud"}); diff != "" {
t.Fatalf("unexpected saved models (-want +got):\n%s", diff)
}
if aliasSyncCalled {
t.Fatal("expected Claude launch flow not to sync aliases")
}
}
func TestLaunchIntegration_ClaudeForceConfigureReprompts(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
binDir := t.TempDir()
writeFakeBinary(t, binDir, "claude")
t.Setenv("PATH", binDir)
if err := config.SaveIntegration("claude", []string{"qwen3:8b"}); err != nil {
t.Fatalf("failed to seed config: %v", err)
}
var selectorCalls int
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
selectorCalls++
return "glm-5:cloud", nil
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"qwen3:8b"}]}`)
case "/api/show":
fmt.Fprint(w, `{"model":"qwen3:8b"}`)
case "/api/me":
fmt.Fprint(w, `{"name":"test-user"}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
Name: "claude",
ForceConfigure: true,
}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
if selectorCalls != 1 {
t.Fatalf("expected forced configure to reprompt for model selection, got %d calls", selectorCalls)
}
saved, err := config.LoadIntegration("claude")
if err != nil {
t.Fatalf("failed to reload saved config: %v", err)
}
if saved.Models[0] != "glm-5:cloud" {
t.Fatalf("expected saved primary to be replaced, got %q", saved.Models[0])
}
}
func TestLaunchIntegration_ClaudeModelOverrideSkipsSelector(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
withInteractiveSession(t, true)
binDir := t.TempDir()
writeFakeBinary(t, binDir, "claude")
t.Setenv("PATH", binDir)
var selectorCalls int
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
selectorCalls++
return "", fmt.Errorf("selector should not run when --model override is set")
}
var confirmCalls int
DefaultConfirmPrompt = func(prompt string) (bool, error) {
confirmCalls++
if !strings.Contains(prompt, "glm-4") {
t.Fatalf("expected download prompt for override model, got %q", prompt)
}
return true, nil
}
var pullCalled bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/show":
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `{"error":"model not found"}`)
case "/api/pull":
pullCalled = true
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"status":"success"}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
Name: "claude",
ModelOverride: "glm-4",
}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
if selectorCalls != 0 {
t.Fatalf("expected model override to skip selector, got %d calls", selectorCalls)
}
if confirmCalls == 0 {
t.Fatal("expected missing override model to prompt for download in interactive mode")
}
if !pullCalled {
t.Fatal("expected missing override model to be pulled after confirmation")
}
saved, err := config.LoadIntegration("claude")
if err != nil {
t.Fatalf("failed to reload saved config: %v", err)
}
if saved.Models[0] != "glm-4" {
t.Fatalf("expected saved primary to match override, got %q", saved.Models[0])
}
}
func TestLaunchIntegration_ConfigureOnlyPrompt(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
runner := &launcherSingleRunner{}
withIntegrationOverride(t, "stubsingle", runner)
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
return "llama3.2", nil
}
var prompts []string
DefaultConfirmPrompt = func(prompt string) (bool, error) {
prompts = append(prompts, prompt)
if strings.Contains(prompt, "Launch StubSingle now?") {
return false, nil
}
return true, nil
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`)
case "/api/show":
fmt.Fprint(w, `{"model":"llama3.2"}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
Name: "stubsingle",
ForceConfigure: true,
ConfigureOnly: true,
}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
if runner.ranModel != "" {
t.Fatalf("expected configure-only flow to skip launch when prompt is declined, got %q", runner.ranModel)
}
if !slices.Contains(prompts, "Launch StubSingle now?") {
t.Fatalf("expected launch confirmation prompt, got %v", prompts)
}
}
func TestLaunchIntegration_ModelOverrideHeadlessMissingFailsWithoutPrompt(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
withInteractiveSession(t, false)
binDir := t.TempDir()
writeFakeBinary(t, binDir, "droid")
t.Setenv("PATH", binDir)
runner := &launcherSingleRunner{}
withIntegrationOverride(t, "droid", runner)
confirmCalled := false
DefaultConfirmPrompt = func(prompt string) (bool, error) {
confirmCalled = true
return true, nil
}
pullCalled := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/show":
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `{"error":"model not found"}`)
case "/api/pull":
pullCalled = true
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"status":"success"}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
Name: "droid",
ModelOverride: "missing-model",
})
if err == nil {
t.Fatal("expected missing model to fail in headless mode")
}
if !strings.Contains(err.Error(), "ollama pull missing-model") {
t.Fatalf("expected actionable missing model error, got %v", err)
}
if confirmCalled {
t.Fatal("expected no confirmation prompt in headless mode")
}
if pullCalled {
t.Fatal("expected pull request not to run in headless mode")
}
if runner.ranModel != "" {
t.Fatalf("expected launch to abort before running integration, got %q", runner.ranModel)
}
}
func TestLaunchIntegration_ModelOverrideHeadlessCanOverrideMissingModelPolicy(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
withInteractiveSession(t, false)
binDir := t.TempDir()
writeFakeBinary(t, binDir, "droid")
t.Setenv("PATH", binDir)
runner := &launcherSingleRunner{}
withIntegrationOverride(t, "droid", runner)
confirmCalled := false
DefaultConfirmPrompt = func(prompt string) (bool, error) {
confirmCalled = true
if !strings.Contains(prompt, "missing-model") {
t.Fatalf("expected prompt to mention missing model, got %q", prompt)
}
return true, nil
}
pullCalled := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/show":
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `{"error":"model not found"}`)
case "/api/pull":
pullCalled = true
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"status":"success"}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
customPolicy := LaunchPolicy{MissingModel: LaunchMissingModelPromptToPull}
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
Name: "droid",
ModelOverride: "missing-model",
Policy: &customPolicy,
}); err != nil {
t.Fatalf("expected policy override to allow prompt/pull in headless mode, got %v", err)
}
if !confirmCalled {
t.Fatal("expected confirmation prompt when missing-model policy is overridden to prompt/pull")
}
if !pullCalled {
t.Fatal("expected pull request to run when missing-model policy is overridden to prompt/pull")
}
if runner.ranModel != "missing-model" {
t.Fatalf("expected integration to launch after pull, got %q", runner.ranModel)
}
}
func TestLaunchIntegration_ModelOverrideInteractiveMissingPromptsAndPulls(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
withInteractiveSession(t, true)
binDir := t.TempDir()
writeFakeBinary(t, binDir, "droid")
t.Setenv("PATH", binDir)
runner := &launcherSingleRunner{}
withIntegrationOverride(t, "droid", runner)
confirmCalled := false
DefaultConfirmPrompt = func(prompt string) (bool, error) {
confirmCalled = true
if !strings.Contains(prompt, "missing-model") {
t.Fatalf("expected prompt to mention missing model, got %q", prompt)
}
return true, nil
}
pullCalled := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/show":
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `{"error":"model not found"}`)
case "/api/pull":
pullCalled = true
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"status":"success"}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
Name: "droid",
ModelOverride: "missing-model",
})
if err != nil {
t.Fatalf("expected interactive override to prompt/pull and succeed, got %v", err)
}
if !confirmCalled {
t.Fatal("expected interactive flow to prompt before pulling missing model")
}
if !pullCalled {
t.Fatal("expected pull request to run after interactive confirmation")
}
if runner.ranModel != "missing-model" {
t.Fatalf("expected integration to run with pulled model, got %q", runner.ranModel)
}
}
func TestLaunchIntegration_HeadlessSelectorFlowFailsWithoutPrompt(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
withInteractiveSession(t, false)
binDir := t.TempDir()
writeFakeBinary(t, binDir, "droid")
t.Setenv("PATH", binDir)
runner := &launcherSingleRunner{}
withIntegrationOverride(t, "droid", runner)
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
return "missing-model", nil
}
confirmCalled := false
DefaultConfirmPrompt = func(prompt string) (bool, error) {
confirmCalled = true
return true, nil
}
pullCalled := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`)
case "/api/show":
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `{"error":"model not found"}`)
case "/api/pull":
pullCalled = true
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"status":"success"}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
Name: "droid",
ForceConfigure: true,
})
if err == nil {
t.Fatal("expected headless selector flow to fail on missing model")
}
if !strings.Contains(err.Error(), "ollama pull missing-model") {
t.Fatalf("expected actionable missing model error, got %v", err)
}
if confirmCalled {
t.Fatal("expected no confirmation prompt in headless selector flow")
}
if pullCalled {
t.Fatal("expected no pull request in headless selector flow")
}
if runner.ranModel != "" {
t.Fatalf("expected flow to abort before launch, got %q", runner.ranModel)
}
}
type apiShowRequest struct {
Model string `json:"model"`
}
func compareStrings(got, want []string) string {
if slices.Equal(got, want) {
return ""
}
return fmt.Sprintf("want %v got %v", want, got)
}
func compareStringSlices(got, want [][]string) string {
if len(got) != len(want) {
return fmt.Sprintf("want %v got %v", want, got)
}
for i := range got {
if !slices.Equal(got[i], want[i]) {
return fmt.Sprintf("want %v got %v", want, got)
}
}
return ""
}