package tui
import (
"context"
"errors"
"fmt"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/johannSo/codey/internal/config"
"github.com/johannSo/codey/internal/llm"
"github.com/johannSo/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)
}
}
// newLoginWizard is newClaudeWizard on a machine where the Claude Code login
// question does or does not arise, with n Claude skills.
func newLoginWizard(t *testing.T, offer bool, n int) *wizard {
t.Helper()
w := newClaudeWizard(t, n, false)
w.offerClaudeCodeAuth = offer
return w
}
// Someone with an API key has nothing to decide, and a machine without
// Claude Code has nothing to borrow: the question is asked only where main
// says it arises, and it comes straight after the theme, ahead of the other
// question about Claude Code.
func TestTheClaudeCodeLoginStepIsOnlyAskedWhenItArises(t *testing.T) {
tests := []struct {
name string
offer bool
n int
want wizStep
}{
{name: "not offered", offer: false, n: 0, want: stepDone},
{name: "not offered, skills", offer: false, n: 3, want: stepClaudeSkills},
{name: "offered", offer: true, n: 0, want: stepClaudeCodeAuth},
{name: "offered, skills", offer: true, n: 3, want: stepClaudeCodeAuth},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
w := newLoginWizard(t, tc.offer, tc.n)
// 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)
}
if tc.offer && w.rails() != baseRailSteps+1+boolInt(tc.n > 0) {
t.Errorf("rails = %d: the login question is not counted", w.rails())
}
})
}
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
// Enter through the step is no: the cursor opens on it, the terms are on
// screen before anyone moves it, and only moving to yes and pressing enter
// writes "on".
func TestTheClaudeCodeLoginStepRecordsTheAnswer(t *testing.T) {
tests := []struct {
name string
down int
want string
}{
{name: "no is the row the cursor opens on", down: 0, want: config.ClaudeCodeAuthOff},
{name: "yes", down: 1, want: config.ClaudeCodeAuthOn},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
w := newLoginWizard(t, true, 0)
enterThrough(w, 6)
if w.step != stepClaudeCodeAuth {
t.Fatalf("the walk did not reach the login step, stopped at %v", w.step)
}
if view := w.View(); !strings.Contains(view, "Claude Code login") || !strings.Contains(view, "terms") {
t.Errorf("the step does not ask about the login with the terms on screen:\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.ClaudeCodeAuth != tc.want {
t.Errorf("claude_code_auth = %q, want %q", w.result.ClaudeCodeAuth, tc.want)
}
})
}
}
// Re-running setup opens the step on what is in force, so enter through it
// changes nothing — and esc goes back to the theme.
func TestTheClaudeCodeLoginStepPreselectsAndGoesBack(t *testing.T) {
w := newLoginWizard(t, true, 0)
w.cfg.ClaudeCodeAuth = config.ClaudeCodeAuthOn
enterThrough(w, 6)
if got := w.options()[w.cursor].value; got != config.ClaudeCodeAuthOn {
t.Errorf("the step opened on %q, want the configured %q", got, config.ClaudeCodeAuthOn)
}
w.Update(key("esc"))
settle(w)
if w.step != stepTheme {
t.Errorf("esc from the login step landed on %v, want the theme", w.step)
}
}
// The wizard is told by main whether to ask, through the options.
func TestOpenWizardPassesOnTheLoginQuestion(t *testing.T) {
m := newTestModel(t)
m.opts.OfferClaudeCodeAuth = true
m.openWizard()
if !m.wiz.offerClaudeCodeAuth {
t.Error("the wizard was not told the login question arises")
}
}
// The session's client was built before the wizard opened, so an answer that
// differs from what it is running on says when it will take effect.
func TestALoginAnswerTheSessionCannotTakeSaysSo(t *testing.T) {
m := newTestModel(t)
m.opts.SaveConfig = func(config.Config) error { return nil }
m.opts.OfferClaudeCodeAuth = true
m.opts.ClaudeCodeAuth = config.ClaudeCodeAuthOn
m.wiz = newWizard(nil, m.opts.Config, 0, false)
m.wiz.offerClaudeCodeAuth = true
m.wiz.cfg.ClaudeCodeAuth = config.ClaudeCodeAuthOff
m.wiz.finish()
runCmd(t, m.closeWizard())
if view := m.tr.render(); !strings.Contains(view, "Claude Code login: off") || !strings.Contains(view, "next start") {
t.Errorf("turning the login off mid-session does not say when it takes effect:\n%s", view)
}
}