package tui import ( "context" "errors" "fmt" "strings" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "codey/internal/config" "codey/internal/llm" "codey/internal/skills" ) // 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, 0, false) // 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: five questions, five 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) // Web search: leave it on the default, "none". if w.step != stepWebSearch { t.Fatalf("after the approvals, step is %v", w.step) } w.Update(key("enter")) settle(w) // Theme: pick the second one. if w.step != stepTheme { t.Fatalf("after the web search, 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.WebSearch != config.WebSearchNone { t.Errorf("web search = %q, want %q", got.WebSearch, config.WebSearchNone) } 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 pressing enter through it is a no-op. func TestWizardPreselectsWhatIsAlreadyConfigured(t *testing.T) { cur := config.Config{ Model: "claude-haiku-4-5", Effort: "low", Approvals: config.ApprovalsAcceptAll, WebSearch: config.WebSearchClaude, Theme: "iris", } w := newTestWizard(t, cur) for i := 0; i < 6 && w.result == nil; i++ { w.Update(key("enter")) settle(w) } if w.result == nil { t.Fatal("pressing enter through every step did not finish the wizard") } want := cur want.Setup = config.SetupDone // the one thing finishing does add if !w.result.Equal(want) { t.Errorf("accepting every default changed the config:\n got %+v\nwant %+v", *w.result, want) } } // Finishing is what the marker means, so it must come back on the result and // not be something the wizard hands out before the last question is answered. func TestWizardMarksTheSetupDoneOnlyWhenItFinishes(t *testing.T) { w := newTestWizard(t, config.Config{}) w.Update(key("enter")) // → model w.Update(key("enter")) // → effort if w.cfg.Setup != "" { t.Errorf("the setup was marked done two questions in: %q", w.cfg.Setup) } for i := 0; i < 5 && w.result == nil; i++ { w.Update(key("enter")) settle(w) } if w.result == nil { t.Fatal("the wizard did not finish") } if !w.result.Done() { t.Errorf("a finished wizard did not mark the setup done: %+v", *w.result) } } // 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{}, 0, false) 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{}, 0, false) 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 < 6 && 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 { // Both conditional steps offered, so every screen there is gets drawn. w := newWizard(nil, config.Config{}, 14, true) w.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]}) w.Update(modelsMsg{models: pricedModels()}) for _, step := range []wizStep{stepIntro, stepModel, stepEffort, stepApprovals, stepWebSearch, stepTheme, stepClaudeSkills, stepHerdr, 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") } // /set is a prefix of both, and the menu offers them in catalogue order: // /settings first, because it is the one reached for more than once. One // letter more is unambiguous, which is what has to keep working. if got := matchCommands(m.commands(), "setu"); len(got) == 0 || got[0].name != "setup" { t.Errorf("typing /setu does not offer /setup first: %v", got) } if got := matchCommands(m.commands(), "sett"); len(got) == 0 || got[0].name != "settings" { t.Errorf("typing /sett does not offer /settings 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) } } // The wizard saves the whole config, but only asks about four of its fields. // A setting it never mentions has to survive being carried through it, or // running /setup would quietly turn auto-compaction back on. func TestWizardKeepsSettingsItDoesNotAskAbout(t *testing.T) { cur := config.Config{ Model: "claude-opus-5", Effort: "high", Approvals: config.ApprovalsAsk, Theme: "ember", AutoCompact: config.AutoCompactOff, } w := newWizard(nil, cur, 0, false) w.reduced = true for w.step != stepDone && w.result == nil { before := w.step w.advance() if w.step == before { t.Fatalf("the wizard stopped advancing at step %d", w.step) } } w.finish() if w.result == nil { t.Fatal("the wizard produced no config") } if w.result.AutoCompact != config.AutoCompactOff { t.Errorf("auto_compact = %q, want it carried through untouched", w.result.AutoCompact) } } // The web-search step is the only question whose answer can be a command line, // so picking "a command on this machine" has to open the box already filled in // with something that works — and pressing enter on that default must not pin // the user to today's command line by writing it into their config. func TestWizardCustomSearchCommand(t *testing.T) { w := newTestWizard(t, config.Config{}) w.step = stepWebSearch w.restoreCursor() w.cursor = w.indexOf(w.options(), config.WebSearchCustom) w.Update(key("enter")) settle(w) if !w.typing { t.Fatal("choosing the custom command did not open the entry box") } if got := w.custom.Value(); got != config.DefaultSearchCommand { t.Errorf("the box opened on %q, want the default %q", got, config.DefaultSearchCommand) } if !strings.Contains(w.View(), "{{query}}") { t.Error("the entry screen does not explain the placeholders") } // Accepting the default stores the mode but no command, so a later build // shipping a better one is picked up. w.Update(key("enter")) settle(w) if w.typing { t.Fatal("enter did not close the entry box") } if w.cfg.WebSearch != config.WebSearchCustom { t.Errorf("mode = %q, want %q", w.cfg.WebSearch, config.WebSearchCustom) } if w.cfg.SearchCommand != "" { t.Errorf("accepting the default wrote it into the config: %q", w.cfg.SearchCommand) } if w.step != stepTheme { t.Errorf("after the command, step is %v, want the theme", w.step) } // An edited command is kept verbatim. w.step = stepWebSearch w.cursor = w.indexOf(w.options(), config.WebSearchCustom) w.Update(key("enter")) settle(w) w.custom.SetValue("mysearch {{query}}") w.Update(key("enter")) settle(w) if w.cfg.SearchCommand != "mysearch {{query}}" { t.Errorf("search command = %q, want the edited one", w.cfg.SearchCommand) } } // Escaping the box leaves the step as it was rather than half-answering it: // the mode must not flip to custom on the strength of a question the user // backed out of. func TestWizardEscapingTheSearchBoxChangesNothing(t *testing.T) { w := newTestWizard(t, config.Config{}) w.step = stepWebSearch w.restoreCursor() w.cursor = w.indexOf(w.options(), config.WebSearchCustom) w.Update(key("enter")) settle(w) w.Update(key("esc")) settle(w) if w.typing { t.Error("esc did not close the entry box") } if w.cfg.WebSearch != config.WebSearchNone { t.Errorf("mode = %q, want it left at %q", w.cfg.WebSearch, config.WebSearchNone) } if w.step != stepWebSearch { t.Errorf("esc moved off the step: %v", w.step) } } // The bug this exists to stop: the wizard writes "web_search": "claude" to the // file, the session it was run in cannot adopt it — the tool set and the // hosted tool definitions were built before the first turn — and nothing says // so. What you get is an agent that cannot search and a config file insisting // that it can, twelve seconds apart. func TestWizardSaysWebSearchNeedsARestart(t *testing.T) { m := newTestModel(t) m.opts.WebSearch = config.WebSearchNone // what this process is running with m.opts.SaveConfig = func(config.Config) error { return nil } m.wiz = newWizard(nil, m.opts.Config, 0, false) m.wiz.cfg.WebSearch = config.WebSearchClaude m.wiz.finish() m.closeWizard() view := m.tr.render() if !strings.Contains(view, "next start") { t.Errorf("the wizard accepted a web-search answer without saying it needs a restart:\n%s", view) } if !strings.Contains(view, "hosted") { t.Errorf("the notice does not say which answer it is about:\n%s", view) } } // The other half: an answer the session already matches must not nag about a // restart it does not need. func TestWizardIsQuietWhenWebSearchDidNotChange(t *testing.T) { m := newTestModel(t) m.opts.WebSearch = config.WebSearchNone m.opts.SaveConfig = func(config.Config) error { return nil } m.wiz = newWizard(nil, m.opts.Config, 0, false) m.wiz.cfg.WebSearch = config.WebSearchNone m.wiz.finish() m.closeWizard() if view := m.tr.render(); strings.Contains(view, "next start") { t.Errorf("an unchanged answer asked for a restart:\n%s", view) } } // newHerdrWizard is newTestWizard on a machine that has herdr, which is the // only place the skill question is asked. func newHerdrWizard(t *testing.T) *wizard { t.Helper() t.Cleanup(func() { applyTheme(defaultTheme) }) w := newWizard(nil, config.Config{}, 0, true) w.reduced = false w.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) w.Update(modelsMsg{models: pricedModels()}) w.anim, w.frame = animNone, introFrames return w } // enterThrough presses enter until the wizard finishes or the presses run out. func enterThrough(w *wizard, n int) { for i := 0; i < n && w.result == nil && !w.cancelled; i++ { w.Update(key("enter")) settle(w) } } // Asking someone who has never heard of herdr whether they want its skill is // noise in the first five minutes of using Codey. func TestTheHerdrStepIsOnlyAskedWhereHerdrIs(t *testing.T) { tests := []struct { name string offer bool want wizStep }{ {name: "no herdr on the machine", offer: false, want: stepDone}, {name: "herdr present", offer: true, want: stepHerdr}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { w := newWizard(nil, config.Config{}, 0, tc.offer) w.reduced = false w.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) w.Update(modelsMsg{models: pricedModels()}) w.anim, w.frame = animNone, introFrames // Intro, model, effort, approvals, web search, theme. enterThrough(w, 6) if w.step != tc.want { t.Errorf("after the theme step the wizard is on %v, want %v", w.step, tc.want) } }) } } // The rail counts the questions this run will actually ask; a node nobody // stands on is a progress bar that never fills. func TestTheRailCountsTheHerdrStepOnlyWhenItIsAsked(t *testing.T) { if got := newWizard(nil, config.Config{}, 0, false).rails(); got != baseRailSteps { t.Errorf("rails = %d without herdr, want %d", got, baseRailSteps) } if got := newWizard(nil, config.Config{}, 0, true).rails(); got != baseRailSteps+1 { t.Errorf("rails = %d with herdr, want %d", got, baseRailSteps+1) } } func TestTheHerdrStepRecordsTheAnswer(t *testing.T) { tests := []struct { name string down int want bool }{ {name: "yes is the row the cursor opens on", down: 0, want: true}, {name: "no", down: 1, want: false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { w := newHerdrWizard(t) enterThrough(w, 6) if w.step != stepHerdr { t.Fatalf("the walk did not reach the herdr step, stopped at %v", w.step) } down(w, tc.down) w.Update(key("enter")) settle(w) if w.result == nil { t.Fatal("answering the last question did not finish the wizard") } if w.installSkill != tc.want { t.Errorf("installSkill = %v, want %v", w.installSkill, tc.want) } }) } } // The answer is an act, not a preference: nothing about it belongs in the file // that is read on every launch. func TestTheHerdrAnswerIsNotSavedAsConfig(t *testing.T) { w := newHerdrWizard(t) enterThrough(w, 7) if w.result == nil { t.Fatal("the walk did not finish") } if !w.installSkill { t.Fatal("this walk was supposed to accept the install") } want := config.Config{ Model: w.result.Model, Effort: w.result.Effort, Approvals: config.ApprovalsAsk, WebSearch: config.WebSearchNone, Theme: w.result.Theme, Setup: config.SetupDone, } if !w.result.Equal(want) { t.Errorf("the herdr answer leaked into the config:\n got %+v\nwant %+v", *w.result, want) } } func TestBackFromTheHerdrStepReturnsToTheTheme(t *testing.T) { w := newHerdrWizard(t) enterThrough(w, 6) if w.step != stepHerdr { t.Fatalf("the walk did not reach the herdr step, stopped at %v", w.step) } w.Update(key("esc")) settle(w) if w.step != stepTheme { t.Errorf("esc from the herdr step landed on %v, want the theme step", w.step) } } // runCmd drains a command, including a batch of them, and returns every // message that came out. The install answers from another goroutine, so this // is how a test gets to the notice it produces. func runCmd(t *testing.T, cmd tea.Cmd) []tea.Msg { t.Helper() if cmd == nil { return nil } msg := cmd() switch m := msg.(type) { case nil: return nil case tea.BatchMsg: var out []tea.Msg for _, c := range m { out = append(out, runCmd(t, c)...) } return out default: return []tea.Msg{msg} } } // deliver hands the model whatever the install answered with. func deliver(m *model, msgs []tea.Msg) { for _, msg := range msgs { if tagged, ok := msg.(forTab); ok { msg = tagged.msg } if skill, ok := msg.(herdrSkillMsg); ok { m.onHerdrSkill(skill) } } } func TestSayingYesInstallsTheSkillAndSaysWhere(t *testing.T) { m := newTestModel(t) m.opts.SaveConfig = func(config.Config) error { return nil } m.opts.HerdrPresent = true called := 0 m.opts.InstallHerdrSkill = func(context.Context) (string, error) { called++ return "/home/u/.agents/skills/herdr/SKILL.md", nil } m.wiz = newWizard(nil, m.opts.Config, 0, true) m.wiz.installSkill = true m.wiz.finish() deliver(m, runCmd(t, m.closeWizard())) if called != 1 { t.Fatalf("the installer ran %d times, want once", called) } view := m.tr.render() if !strings.Contains(view, "/home/u/.agents/skills/herdr/SKILL.md") { t.Errorf("the notice does not name the file that was written:\n%s", view) } // Skills are listed into the cached prompt prefix at startup, so this // session genuinely cannot use what was just installed. if !strings.Contains(view, "next start") { t.Errorf("the notice does not say when the skill takes effect:\n%s", view) } } // A failed download is not a failed setup: the answers are already saved, and // the only thing lost is a file that can be fetched again. func TestAFailedInstallIsReportedWithoutLosingTheAnswers(t *testing.T) { m := newTestModel(t) saved := 0 m.opts.SaveConfig = func(config.Config) error { saved++; return nil } m.opts.HerdrPresent = true m.opts.InstallHerdrSkill = func(context.Context) (string, error) { return "", errors.New("dial tcp: no route to host") } m.wiz = newWizard(nil, m.opts.Config, 0, true) m.wiz.installSkill = true m.wiz.finish() deliver(m, runCmd(t, m.closeWizard())) if saved != 1 { t.Errorf("the config was saved %d times, want once regardless of the install", saved) } view := m.tr.render() if !strings.Contains(view, "no route to host") { t.Errorf("the failure does not say what went wrong:\n%s", view) } if !strings.Contains(view, "/setup") { t.Errorf("the failure does not say how to try again:\n%s", view) } } func TestSayingNoInstallsNothing(t *testing.T) { m := newTestModel(t) m.opts.SaveConfig = func(config.Config) error { return nil } m.opts.HerdrPresent = true called := 0 m.opts.InstallHerdrSkill = func(context.Context) (string, error) { called++ return "", nil } m.wiz = newWizard(nil, m.opts.Config, 0, true) m.wiz.installSkill = false m.wiz.finish() runCmd(t, m.closeWizard()) if called != 0 { t.Errorf("the installer ran %d times after the answer was no", called) } } // Cancelling the wizard cancels everything it was going to do. func TestCancellingTheWizardInstallsNothing(t *testing.T) { m := newTestModel(t) m.opts.HerdrPresent = true called := 0 m.opts.InstallHerdrSkill = func(context.Context) (string, error) { called++ return "", nil } m.wiz = newWizard(nil, m.opts.Config, 0, true) m.wiz.installSkill = true m.wiz.cancel() runCmd(t, m.closeWizard()) if called != 0 { t.Errorf("the installer ran %d times for a wizard that was escaped out of", called) } } // The rail and the counter are computed from the step's ordinal, so the step // that is sometimes not there is the one that can put them out. func TestTheHerdrStepDrawsAsTheLastOfSix(t *testing.T) { w := newHerdrWizard(t) enterThrough(w, 6) if w.step != stepHerdr { t.Fatalf("the walk did not reach the herdr step, stopped at %v", w.step) } view := w.View() if !strings.Contains(view, "step 6 of 6") { t.Errorf("the header does not count the herdr step as the last of six:\n%s", view) } if !strings.Contains(view, "herdr") { t.Errorf("the herdr step does not mention herdr:\n%s", view) } if !strings.Contains(view, skills.Dirname) { t.Errorf("the step does not say where the file goes:\n%s", view) } } // Without the step, nothing may claim there is a sixth. func TestWithoutHerdrTheRailStillEndsAtFive(t *testing.T) { w := newTestWizard(t, config.Config{}) enterThrough(w, 5) if w.step != stepTheme { t.Fatalf("the walk stopped at %v, want the theme step", w.step) } if view := w.View(); !strings.Contains(view, "step 5 of 5") { t.Errorf("the header miscounts without the herdr step:\n%s", view) } } // newClaudeWizard is newTestWizard on a machine where Claude Code has n // skills, with or without herdr, which are the two things that decide which // questions the run asks. func newClaudeWizard(t *testing.T, n int, herdr bool) *wizard { t.Helper() t.Cleanup(func() { applyTheme(defaultTheme) }) w := newWizard(nil, config.Config{}, n, herdr) w.reduced = false w.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) w.Update(modelsMsg{models: pricedModels()}) w.anim, w.frame = animNone, introFrames return w } // A question about skills that are not there is a question nobody can answer. func TestTheClaudeSkillsStepIsOnlyAskedWhenThereAreSome(t *testing.T) { tests := []struct { name string n int herdr bool want wizStep }{ {name: "none, no herdr", n: 0, herdr: false, want: stepDone}, {name: "none, herdr", n: 0, herdr: true, want: stepHerdr}, {name: "some, no herdr", n: 14, herdr: false, want: stepClaudeSkills}, {name: "some, herdr", n: 14, herdr: true, want: stepClaudeSkills}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { w := newClaudeWizard(t, tc.n, tc.herdr) // Intro, model, effort, approvals, web search, theme. enterThrough(w, 6) if w.step != tc.want { t.Errorf("after the theme step the wizard is on %v, want %v", w.step, tc.want) } }) } } // Two conditional steps: the rail has to count exactly the ones asked, and // the counter in the header has to agree with it on every step of every // combination, or one run in four shows "step 6 of 7" with no seventh. func TestTheRailCountsTheQuestionsActuallyAsked(t *testing.T) { tests := []struct { name string n int herdr bool rails int }{ {name: "neither", n: 0, herdr: false, rails: baseRailSteps}, {name: "claude only", n: 3, herdr: false, rails: baseRailSteps + 1}, {name: "herdr only", n: 0, herdr: true, rails: baseRailSteps + 1}, {name: "both", n: 3, herdr: true, rails: baseRailSteps + 2}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { w := newClaudeWizard(t, tc.n, tc.herdr) if got := w.rails(); got != tc.rails { t.Fatalf("rails = %d, want %d", got, tc.rails) } w.Update(key("enter")) // off the intro settle(w) for i := 1; w.result == nil; i++ { want := fmt.Sprintf("step %d of %d", i, tc.rails) if view := w.View(); !strings.Contains(view, want) { t.Errorf("on %v the header does not say %q:\n%s", w.step, want, view) } if got := w.railPos(); got != float64(i-1) { t.Errorf("on %v the marker is at %v, want %d", w.step, got, i-1) } w.Update(key("enter")) settle(w) if i > tc.rails { t.Fatalf("the walk asked more than %d questions", tc.rails) } } }) } } func TestTheClaudeSkillsStepRecordsTheAnswer(t *testing.T) { tests := []struct { name string down int want string }{ {name: "off is the row the cursor opens on", down: 0, want: config.ClaudeSkillsOff}, {name: "use", down: 1, want: config.ClaudeSkillsUse}, {name: "copy", down: 2, want: config.ClaudeSkillsCopy}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { w := newClaudeWizard(t, 14, false) enterThrough(w, 6) if w.step != stepClaudeSkills { t.Fatalf("the walk did not reach the Claude skills step, stopped at %v", w.step) } if view := w.View(); !strings.Contains(view, "14 skills") { t.Errorf("the question does not say how many skills are on offer:\n%s", view) } down(w, tc.down) w.Update(key("enter")) settle(w) if w.result == nil { t.Fatal("answering the last question did not finish the wizard") } if w.result.ClaudeSkills != tc.want { t.Errorf("claude_skills = %q, want %q", w.result.ClaudeSkills, tc.want) } if !config.ValidClaudeSkills(w.result.ClaudeSkills) { t.Errorf("claude_skills = %q is not one the config accepts", w.result.ClaudeSkills) } }) } } // The wizard offers exactly the config's modes, in the config's order: an // answer that cannot be saved is worse than no question. func TestTheClaudeSkillsOptionsAreTheConfigsModes(t *testing.T) { var got []string for _, o := range claudeSkillsOptions() { got = append(got, o.value) if o.desc == "" { t.Errorf("mode %q is offered with no explanation", o.value) } } if strings.Join(got, ",") != strings.Join(config.ClaudeSkillsModes, ",") { t.Errorf("the wizard offers %v, the config accepts %v", got, config.ClaudeSkillsModes) } } // Re-running setup opens the step on the mode in force, so enter through it // changes nothing — the rule every other step follows. func TestTheClaudeSkillsStepPreselectsTheModeInForce(t *testing.T) { w := newClaudeWizard(t, 2, false) w.cfg.ClaudeSkills = config.ClaudeSkillsUse w.step = stepClaudeSkills w.restoreCursor() if got := w.options()[w.cursor].value; got != config.ClaudeSkillsUse { t.Errorf("the step opened on %q, want the configured %q", got, config.ClaudeSkillsUse) } } // Both conditional steps at once: back from herdr lands on the Claude step, // and back from there on the theme. Without the Claude step, back from herdr // skips straight to the theme. func TestBackWalksTheConditionalStepsInOrder(t *testing.T) { w := newClaudeWizard(t, 2, true) enterThrough(w, 7) if w.step != stepHerdr { t.Fatalf("the walk did not reach the herdr step, stopped at %v", w.step) } w.Update(key("esc")) settle(w) if w.step != stepClaudeSkills { t.Errorf("esc from herdr landed on %v, want the Claude skills step", w.step) } w.Update(key("esc")) settle(w) if w.step != stepTheme { t.Errorf("esc from the Claude skills step landed on %v, want the theme", w.step) } w = newClaudeWizard(t, 0, true) enterThrough(w, 6) w.Update(key("esc")) settle(w) if w.step != stepTheme { t.Errorf("without Claude skills, esc from herdr landed on %v, want the theme", w.step) } } func TestSayingCopyCopiesTheSkillsAndSaysHowMany(t *testing.T) { m := newTestModel(t) m.opts.SaveConfig = func(config.Config) error { return nil } m.opts.ClaudeSkills = 14 called := 0 m.opts.CopyClaudeSkills = func() (int, error) { called++; return 14, nil } m.wiz = newWizard(nil, m.opts.Config, 14, false) m.wiz.cfg.ClaudeSkills = config.ClaudeSkillsCopy m.wiz.finish() runCmd(t, m.closeWizard()) if called != 1 { t.Fatalf("the copy ran %d times, want once", called) } view := m.tr.render() if !strings.Contains(view, "copied 14") { t.Errorf("the notice does not say how many were copied:\n%s", view) } if !strings.Contains(view, skills.Dirname) { t.Errorf("the notice does not say where they went:\n%s", view) } if !strings.Contains(view, "next start") { t.Errorf("the notice does not say when they take effect:\n%s", view) } } // The copy is a one-off act the answer asks for; the other two answers ask // for nothing to be done now. func TestOtherAnswersCopyNothing(t *testing.T) { for _, mode := range []string{config.ClaudeSkillsOff, config.ClaudeSkillsUse} { t.Run(mode, func(t *testing.T) { m := newTestModel(t) m.opts.SaveConfig = func(config.Config) error { return nil } called := 0 m.opts.CopyClaudeSkills = func() (int, error) { called++; return 0, nil } m.wiz = newWizard(nil, m.opts.Config, 14, false) m.wiz.cfg.ClaudeSkills = mode m.wiz.finish() runCmd(t, m.closeWizard()) if called != 0 { t.Errorf("the copy ran %d times after the answer was %q", called, mode) } }) } } // A failed copy is not a failed setup: the answer is saved, and /setup is how // to have another go. func TestAFailedCopyIsReportedWithoutLosingTheAnswers(t *testing.T) { m := newTestModel(t) saved := 0 m.opts.SaveConfig = func(config.Config) error { saved++; return nil } m.opts.CopyClaudeSkills = func() (int, error) { return 0, errors.New("permission denied") } m.wiz = newWizard(nil, m.opts.Config, 14, false) m.wiz.cfg.ClaudeSkills = config.ClaudeSkillsCopy m.wiz.finish() runCmd(t, m.closeWizard()) if saved != 1 { t.Errorf("the config was saved %d times, want once regardless of the copy", saved) } view := m.tr.render() if !strings.Contains(view, "permission denied") { t.Errorf("the failure does not say what went wrong:\n%s", view) } if !strings.Contains(view, "/setup") { t.Errorf("the failure does not say how to try again:\n%s", view) } } func TestCancellingTheWizardCopiesNothing(t *testing.T) { m := newTestModel(t) called := 0 m.opts.CopyClaudeSkills = func() (int, error) { called++; return 0, nil } m.wiz = newWizard(nil, m.opts.Config, 14, false) m.wiz.cfg.ClaudeSkills = config.ClaudeSkillsCopy m.wiz.cancel() runCmd(t, m.closeWizard()) if called != 0 { t.Errorf("the copy ran %d times for a wizard that was escaped out of", called) } }