package tui import ( "strings" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "harness/internal/config" "harness/internal/llm" ) // newTestWizard builds a wizard on a comfortable screen with the catalogue // already delivered, which is the state every question is answered in. func newTestWizard(t *testing.T, cur config.Config) *wizard { t.Helper() t.Cleanup(func() { applyTheme(defaultTheme) }) w := newWizard(nil, cur) // nil client: no fetch, the fallback list is used w.reduced = false w.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) w.Update(modelsMsg{models: pricedModels()}) // Past the intro, where every walk below starts. w.anim, w.frame = animNone, introFrames return w } // down presses the down arrow n times. func down(w *wizard, n int) { for i := 0; i < n; i++ { w.Update(key("down")) } } // settle runs the wizard's clock until nothing is animating, so a test can // assert on the state a transition lands in rather than on a frame of it. func settle(w *wizard) { for i := 0; i < doneFrames*2 && w.anim != animNone; i++ { w.Update(wizTick{}) } } // The walk a first run actually takes: four questions, four answers, saved. func TestWizardWalkProducesTheChosenConfig(t *testing.T) { w := newTestWizard(t, config.Config{}) w.Update(key("enter")) // intro → model settle(w) if w.step != stepModel { t.Fatalf("enter on the intro landed on step %v", w.step) } // Second model in the list, whatever it is. models := w.options() down(w, 1) wantModel := models[1].value w.Update(key("enter")) settle(w) // Effort: "low" is first. if w.step != stepEffort { t.Fatalf("after the model, step is %v", w.step) } w.cursor = w.indexOf(w.options(), "max") w.Update(key("enter")) settle(w) // Approvals: leave it on the default, "ask". if w.step != stepApprovals { t.Fatalf("after the effort, step is %v", w.step) } w.Update(key("enter")) settle(w) // Theme: pick the second one. if w.step != stepTheme { t.Fatalf("after the approvals, step is %v", w.step) } down(w, 1) wantTheme := w.options()[w.cursor].value w.Update(key("enter")) settle(w) if w.result == nil { t.Fatal("the wizard never finished") } got := *w.result if got.Model != wantModel { t.Errorf("model = %q, want %q", got.Model, wantModel) } if got.Effort != "max" { t.Errorf("effort = %q, want max", got.Effort) } if got.Approvals != config.ApprovalsAsk { t.Errorf("approvals = %q, want %q", got.Approvals, config.ApprovalsAsk) } if got.Theme != wantTheme { t.Errorf("theme = %q, want %q", got.Theme, wantTheme) } } // Re-running setup should not silently reset what is already configured: every // step opens on the current value, so enter-enter-enter-enter is a no-op. func TestWizardPreselectsWhatIsAlreadyConfigured(t *testing.T) { cur := config.Config{ Model: "claude-haiku-4-5", Effort: "low", Approvals: config.ApprovalsAcceptAll, Theme: "iris", } w := newTestWizard(t, cur) for i := 0; i < 5 && w.result == nil; i++ { w.Update(key("enter")) settle(w) } if w.result == nil { t.Fatal("four enters did not finish the wizard") } if *w.result != cur { t.Errorf("accepting every default changed the config:\n got %+v\nwant %+v", *w.result, cur) } } // esc is back, not cancel: an answer given three steps ago must survive a // walk backwards and forwards again. func TestWizardBackKeepsEarlierAnswers(t *testing.T) { w := newTestWizard(t, config.Config{}) w.Update(key("enter")) // → model settle(w) down(w, 2) want := w.options()[w.cursor].value w.Update(key("enter")) // → effort settle(w) w.Update(key("esc")) // back to model settle(w) if w.step != stepModel { t.Fatalf("esc on the effort step landed on %v", w.step) } if got := w.options()[w.cursor].value; got != want { t.Errorf("stepping back lost the highlight: on %q, want %q", got, want) } } // ctrl+c abandons everything, including the theme the preview applied. func TestWizardCancelRestoresTheme(t *testing.T) { applyTheme("ember") w := newTestWizard(t, config.Config{}) w.step, w.cursor = stepTheme, 0 w.restoreCursor() down(w, 2) // previews by applying for real if currentTheme.name == "ember" { t.Fatal("the theme step did not preview by applying the palette") } w.Update(key("ctrl+c")) if !w.cancelled { t.Error("ctrl+c did not cancel") } if w.result != nil { t.Error("a cancelled wizard produced a config") } if currentTheme.name != "ember" { t.Errorf("cancel left the theme on %q instead of restoring ember", currentTheme.name) } } // The wizard has to stay usable against an endpoint with no catalogue: a // gateway that 404s /v1/models is a normal thing to be pointed at. func TestWizardFallsBackWhenTheCatalogueFails(t *testing.T) { w := newWizard(nil, config.Config{}) w.reduced = false w.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) w.anim, w.frame = animNone, introFrames w.Update(modelsMsg{err: errFetch{}}) w.step = stepModel w.restoreCursor() opts := w.options() if len(opts) < 2 { t.Fatalf("a failed fetch left %d options", len(opts)) } if opts[0].value == customModel { t.Error("the fallback offered nothing but the custom entry") } // And it has to say so rather than pretending the short list is the truth. if view := w.View(); !strings.Contains(view, "couldn't reach") { t.Error("the wizard does not report that the catalogue was unreachable") } } type errFetch struct{} func (errFetch) Error() string { return "no catalogue here" } // The custom entry is what makes the wizard usable against a gateway serving // a model this build has never heard of. func TestWizardCustomModelAcceptsAnyID(t *testing.T) { w := newTestWizard(t, config.Config{}) w.step = stepModel w.restoreCursor() w.cursor = w.indexOf(w.options(), customModel) w.Update(key("enter")) if !w.typing { t.Fatal("the custom entry did not open a text field") } for _, r := range "my-gateway/llama-3" { w.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) } w.Update(key("enter")) settle(w) if w.cfg.Model != "my-gateway/llama-3" { t.Errorf("model = %q, want the typed id", w.cfg.Model) } if w.step != stepEffort { t.Errorf("accepting a custom id landed on %v, want the effort step", w.step) } } // An empty custom id must not be accepted: it would be saved as "no model", // which silently means the built-in default on the next run. func TestWizardRejectsAnEmptyCustomModel(t *testing.T) { w := newTestWizard(t, config.Config{}) w.step = stepModel w.restoreCursor() w.cursor = w.indexOf(w.options(), customModel) w.Update(key("enter")) w.Update(key("enter")) // nothing typed if !w.typing { t.Error("an empty id was accepted") } if w.step != stepModel { t.Errorf("an empty id advanced to %v", w.step) } } // Ticking has to stop. A wizard that keeps scheduling frames after it is done // burns a core until the process exits. func TestWizardStopsTickingWhenFinished(t *testing.T) { w := newTestWizard(t, config.Config{}) w.step = stepTheme w.restoreCursor() w.Update(key("enter")) // → done, which animates var cmd tea.Cmd for i := 0; i < doneFrames*2; i++ { cmd = w.Update(wizTick{}) if w.result != nil { break } } if w.result == nil { t.Fatal("the closing animation never finished") } if cmd != nil { t.Error("the wizard scheduled another frame after finishing") } } // A terminal that cannot show the animations still has to show the wizard. func TestWizardWithoutAnimationsIsStillAnswerable(t *testing.T) { w := newWizard(nil, config.Config{}) w.reduced = true w.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) w.Update(modelsMsg{models: pricedModels()}) w.Init() // No ticks are scheduled, so the intro must already be over. if w.anim != animNone { t.Errorf("a still wizard opened mid-animation: %v", w.anim) } if cmd := w.Update(wizTick{}); cmd != nil { t.Error("a still wizard scheduled a frame") } for i := 0; i < 5 && w.result == nil; i++ { w.Update(key("enter")) } if w.result == nil { t.Fatal("the wizard cannot be completed without animations") } if view := w.View(); view == "" { t.Error("a still wizard renders nothing") } } // Every step has to fit, and none of it may overflow the terminal it is drawn // into — a wizard that wraps is a wizard that looks broken. func TestWizardFitsItsScreen(t *testing.T) { sizes := [][2]int{{100, 40}, {80, 24}, {62, 21}, {50, 18}} for _, size := range sizes { w := newWizard(nil, config.Config{}) w.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]}) w.Update(modelsMsg{models: pricedModels()}) for _, step := range []wizStep{stepIntro, stepModel, stepEffort, stepApprovals, stepTheme, stepDone} { w.step = step w.restoreCursor() view := w.View() for i, line := range strings.Split(view, "\n") { if got := lipgloss.Width(line); got > size[0] { t.Errorf("%dx%d step %v line %d is %d cells wide", size[0], size[1], step, i, got) } } if h := strings.Count(view, "\n") + 1; h > size[1] { t.Errorf("%dx%d step %v is %d lines tall", size[0], size[1], step, h) } } } } // The theme list the wizard offers and the names the config will accept are // two lists that must not drift apart: a theme you can pick but not save is // worse than one that does not exist. func TestThemeNamesMatchTheConfigsIdeaOfThem(t *testing.T) { got, want := themeNames(), config.Themes if strings.Join(got, ",") != strings.Join(want, ",") { t.Errorf("tui offers %v, config accepts %v", got, want) } for _, name := range want { if lookupTheme(name).name != name { t.Errorf("config accepts %q but the tui has no such theme", name) } } } // Nothing is written to disk by the wizard itself; the caller saves. But the // caller can only save what the wizard fully answers, so no field may come // back empty from a completed walk. func TestAFinishedWizardAnswersEveryField(t *testing.T) { w := newTestWizard(t, config.Config{}) for i := 0; i < 6 && w.result == nil; i++ { w.Update(key("enter")) settle(w) } if w.result == nil { t.Fatal("the wizard never finished") } r := *w.result if r.Model == "" || r.Effort == "" || r.Approvals == "" || r.Theme == "" { t.Errorf("a completed wizard left a field empty: %+v", r) } if !llm.ValidEffort(r.Effort) { t.Errorf("effort %q is not one the API accepts", r.Effort) } if !config.ValidTheme(r.Theme) { t.Errorf("theme %q is not one the config accepts", r.Theme) } } // Every built-in command has to be reachable the same three ways: typed, // offered by the completion menu, and listed by /help. A command that works // but is not in the catalogue is one nobody finds. func TestSetupIsInTheCommandCatalogue(t *testing.T) { m := newTestModel(t) var found bool for _, c := range m.commands() { if c.name == "setup" { found = true if c.desc == "" { t.Error("/setup is in the catalogue with no description") } } } if !found { t.Fatal("/setup is not in the command catalogue") } if !strings.Contains(m.helpText(), "/setup") { t.Error("/help does not list /setup") } if got := matchCommands(m.commands(), "set"); len(got) == 0 || got[0].name != "setup" { t.Errorf("typing /set does not offer /setup first: %v", got) } } // The wizard and the /model picker must describe a model identically — they // are the same list, and two spellings of one model id is a bug people report // as "it changed my model". func TestWizardAndPickerAgreeOnModels(t *testing.T) { models := []llm.Model{{ID: "claude-opus-5", DisplayName: "Claude Opus 5"}} w := newTestWizard(t, config.Config{}) w.Update(modelsMsg{models: models}) w.step = stepModel w.restoreCursor() opts := w.options() if opts[0].value != models[0].ID { t.Errorf("the wizard offers %q, want the id the endpoint gave", opts[0].value) } if opts[0].label != models[0].DisplayName { t.Errorf("the wizard renames the model to %q", opts[0].label) } if want := priceLabel(models[0].ID); opts[0].note != want { t.Errorf("the wizard prices it %q, the picker %q", opts[0].note, want) } } // The effort levels the wizard offers are the API's, not a copy: a level that // only exists in the wizard is a 400 nobody can explain. func TestWizardOffersExactlyTheAPIsEffortLevels(t *testing.T) { var got []string for _, o := range effortOptions() { got = append(got, o.value) } if strings.Join(got, ",") != strings.Join(llm.EffortLevels, ",") { t.Errorf("the wizard offers %v, the API accepts %v", got, llm.EffortLevels) } for _, o := range effortOptions() { if o.desc == "" { t.Errorf("effort %q is offered with no explanation", o.value) } } } // The effort pill reports the running session's level, so the command it names // must be the one that changes that — not the wizard, which changes what the // next session starts with. func TestEffortPillNamesTheCommandThatChangesIt(t *testing.T) { m := newTestModel(t) m.opts.Effort = "high" got := m.activity() if !strings.Contains(got, "/effort") { t.Errorf("the effort pill does not name /effort: %q", got) } if strings.Contains(got, "/setup") { t.Errorf("the effort pill points at /setup, which changes the saved default: %q", got) } }