diff --git a/internal/prompter/huh_prompter.go b/internal/prompter/huh_prompter.go index a6118de9b..7fbd052ad 100644 --- a/internal/prompter/huh_prompter.go +++ b/internal/prompter/huh_prompter.go @@ -24,7 +24,7 @@ func (p *huhPrompter) newForm(groups ...*huh.Group) *huh.Form { WithOutput(p.stdout) } -func (p *huhPrompter) Select(prompt, defaultValue string, options []string) (int, error) { +func (p *huhPrompter) buildSelectForm(prompt, defaultValue string, options []string) (*huh.Form, *int) { var result int if !slices.Contains(options, defaultValue) { @@ -39,19 +39,24 @@ func (p *huhPrompter) Select(prompt, defaultValue string, options []string) (int formOptions[i] = huh.NewOption(o, i) } - err := p.newForm( + form := p.newForm( huh.NewGroup( huh.NewSelect[int](). Title(prompt). Value(&result). Options(formOptions...), ), - ).Run() - - return result, err + ) + return form, &result } -func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { +func (p *huhPrompter) Select(prompt, defaultValue string, options []string) (int, error) { + form, result := p.buildSelectForm(prompt, defaultValue, options) + err := form.Run() + return *result, err +} + +func (p *huhPrompter) buildMultiSelectForm(prompt string, defaults []string, options []string) (*huh.Form, *[]int) { var result []int defaults = slices.DeleteFunc(defaults, func(s string) bool { @@ -66,7 +71,7 @@ func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []st formOptions[i] = huh.NewOption(o, i) } - err := p.newForm( + form := p.newForm( huh.NewGroup( huh.NewMultiSelect[int](). Title(prompt). @@ -74,12 +79,17 @@ func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []st Limit(len(options)). Options(formOptions...), ), - ).Run() + ) + return form, &result +} +func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { + form, result := p.buildMultiSelectForm(prompt, defaults, options) + err := form.Run() if err != nil { return nil, err } - return result, nil + return *result, nil } // searchOptionsBinding is used as the OptionsFunc binding for MultiSelectWithSearch. @@ -91,7 +101,7 @@ type searchOptionsBinding struct { Selected *[]string } -func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) { +func (p *huhPrompter) buildMultiSelectWithSearchForm(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) (*huh.Form, *[]string) { selectedValues := make([]string, len(defaultValues)) copy(selectedValues, defaultValues) @@ -103,13 +113,15 @@ func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, default // Cache searchFunc results locally keyed by query string. // This avoids redundant calls when the OptionsFunc binding hash changes // due to selection changes (not query changes). + searchCacheValid := false var cachedSearchQuery string var cachedSearchResult MultiSelectSearchResult buildOptions := func(query string) []huh.Option[string] { - if query != cachedSearchQuery || cachedSearchResult.Err != nil { + if !searchCacheValid || query != cachedSearchQuery { cachedSearchResult = searchFunc(query) cachedSearchQuery = query + searchCacheValid = true } result := cachedSearchResult @@ -175,7 +187,7 @@ func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, default Selected: &selectedValues, } - err := p.newForm( + form := p.newForm( huh.NewGroup( huh.NewInput(). Title(searchPrompt). @@ -189,67 +201,83 @@ func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, default Value(&selectedValues). Limit(0), ), - ).Run() + ) + return form, &selectedValues +} + +func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) { + form, result := p.buildMultiSelectWithSearchForm(prompt, searchPrompt, defaultValues, persistentValues, searchFunc) + err := form.Run() if err != nil { return nil, err } - - return selectedValues, nil + return *result, nil } -func (p *huhPrompter) Input(prompt, defaultValue string) (string, error) { +func (p *huhPrompter) buildInputForm(prompt, defaultValue string) (*huh.Form, *string) { result := defaultValue - - err := p.newForm( + form := p.newForm( huh.NewGroup( huh.NewInput(). Title(prompt). Value(&result), ), - ).Run() - - return result, err + ) + return form, &result } -func (p *huhPrompter) Password(prompt string) (string, error) { - var result string +func (p *huhPrompter) Input(prompt, defaultValue string) (string, error) { + form, result := p.buildInputForm(prompt, defaultValue) + err := form.Run() + return *result, err +} - err := p.newForm( +func (p *huhPrompter) buildPasswordForm(prompt string) (*huh.Form, *string) { + var result string + form := p.newForm( huh.NewGroup( huh.NewInput(). EchoMode(huh.EchoModePassword). Title(prompt). Value(&result), ), - ).Run() + ) + return form, &result +} +func (p *huhPrompter) Password(prompt string) (string, error) { + form, result := p.buildPasswordForm(prompt) + err := form.Run() if err != nil { return "", err } - return result, nil + return *result, nil } -func (p *huhPrompter) Confirm(prompt string, defaultValue bool) (bool, error) { +func (p *huhPrompter) buildConfirmForm(prompt string, defaultValue bool) (*huh.Form, *bool) { result := defaultValue - - err := p.newForm( + form := p.newForm( huh.NewGroup( huh.NewConfirm(). Title(prompt). Value(&result), ), - ).Run() + ) + return form, &result +} +func (p *huhPrompter) Confirm(prompt string, defaultValue bool) (bool, error) { + form, result := p.buildConfirmForm(prompt, defaultValue) + err := form.Run() if err != nil { return false, err } - return result, nil + return *result, nil } -func (p *huhPrompter) AuthToken() (string, error) { +func (p *huhPrompter) buildAuthTokenForm() (*huh.Form, *string) { var result string - - err := p.newForm( + form := p.newForm( huh.NewGroup( huh.NewInput(). EchoMode(huh.EchoModePassword). @@ -262,12 +290,17 @@ func (p *huhPrompter) AuthToken() (string, error) { }). Value(&result), ), - ).Run() - - return result, err + ) + return form, &result } -func (p *huhPrompter) ConfirmDeletion(requiredValue string) error { +func (p *huhPrompter) AuthToken() (string, error) { + form, result := p.buildAuthTokenForm() + err := form.Run() + return *result, err +} + +func (p *huhPrompter) buildConfirmDeletionForm(requiredValue string) *huh.Form { return p.newForm( huh.NewGroup( huh.NewInput(). @@ -279,28 +312,36 @@ func (p *huhPrompter) ConfirmDeletion(requiredValue string) error { return nil }), ), - ).Run() + ) } -func (p *huhPrompter) InputHostname() (string, error) { - var result string +func (p *huhPrompter) ConfirmDeletion(requiredValue string) error { + return p.buildConfirmDeletionForm(requiredValue).Run() +} - err := p.newForm( +func (p *huhPrompter) buildInputHostnameForm() (*huh.Form, *string) { + var result string + form := p.newForm( huh.NewGroup( huh.NewInput(). Title("Hostname:"). Validate(ghinstance.HostnameValidator). Value(&result), ), - ).Run() + ) + return form, &result +} +func (p *huhPrompter) InputHostname() (string, error) { + form, result := p.buildInputHostnameForm() + err := form.Run() if err != nil { return "", err } - return result, nil + return *result, nil } -func (p *huhPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { +func (p *huhPrompter) buildMarkdownEditorForm(prompt string, blankAllowed bool) (*huh.Form, *string) { var result string skipOption := "skip" launchOption := "launch" @@ -311,20 +352,25 @@ func (p *huhPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed b options = append(options, huh.NewOption("Skip", skipOption)) } - err := p.newForm( + form := p.newForm( huh.NewGroup( huh.NewSelect[string](). Title(prompt). Options(options...). Value(&result), ), - ).Run() + ) + return form, &result +} +func (p *huhPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { + form, result := p.buildMarkdownEditorForm(prompt, blankAllowed) + err := form.Run() if err != nil { return "", err } - if result == skipOption { + if *result == "skip" { return "", nil } diff --git a/internal/prompter/huh_prompter_test.go b/internal/prompter/huh_prompter_test.go new file mode 100644 index 000000000..374cabaa3 --- /dev/null +++ b/internal/prompter/huh_prompter_test.go @@ -0,0 +1,508 @@ +package prompter + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test helpers adapted from huh's own test suite (huh_test.go). + +func batchUpdate(m huh.Model, cmd tea.Cmd) huh.Model { + if cmd == nil { + return m + } + msg := cmd() + m, cmd = m.Update(msg) + if cmd == nil { + return m + } + msg = cmd() + m, _ = m.Update(msg) + return m +} + +func codeKeypress(r rune) tea.KeyPressMsg { + return tea.KeyPressMsg(tea.Key{Code: r}) +} + +func keypress(r rune) tea.KeyPressMsg { + return tea.KeyPressMsg(tea.Key{ + Text: string(r), + Code: r, + ShiftedCode: r, + }) +} + +func typeText(m huh.Model, s string) huh.Model { + for _, r := range s { + m, _ = m.Update(keypress(r)) + } + return m +} + +func viewStripped(m huh.Model) string { + return ansi.Strip(m.View()) +} + +func shiftTabKeypress() tea.KeyPressMsg { + return tea.KeyPressMsg(tea.Key{Code: tea.KeyTab, Mod: tea.ModShift}) +} + +func newTestHuhPrompter() *huhPrompter { + return &huhPrompter{} +} + +// doAllUpdates processes all batched commands from the form, including async +// OptionsFunc evaluations. Adapted from huh's own test suite. Uses iterative +// rounds with a depth limit to prevent infinite loops from cascading binding updates. +func doAllUpdates(f *huh.Form, cmd tea.Cmd) { + for range 3 { + if cmd == nil { + return + } + cmds := expandBatch(cmd) + var next []tea.Cmd + for _, c := range cmds { + if c == nil { + continue + } + _, result := f.Update(c()) + if result != nil { + next = append(next, result) + } + } + if len(next) == 0 { + return + } + cmd = tea.Batch(next...) + } +} + +// expandBatch flattens nested tea.BatchMsg into a flat slice of commands. +func expandBatch(cmd tea.Cmd) []tea.Cmd { + if cmd == nil { + return nil + } + msg := cmd() + if batch, ok := msg.(tea.BatchMsg); ok { + var all []tea.Cmd + for _, sub := range batch { + all = append(all, expandBatch(sub)...) + } + return all + } + return []tea.Cmd{func() tea.Msg { return msg }} +} + +func TestHuhPrompterInput(t *testing.T) { + tests := []struct { + name string + defaultValue string + input string + wantResult string + }{ + { + name: "basic input", + input: "hello", + wantResult: "hello", + }, + { + name: "default value returned when no input", + defaultValue: "default", + wantResult: "default", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newTestHuhPrompter() + f, result := p.buildInputForm("Name:", tt.defaultValue) + f.Update(f.Init()) + + var m huh.Model = f + if tt.input != "" { + m = typeText(m, tt.input) + } + batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) + + require.Equal(t, tt.wantResult, *result) + }) + } +} + +func TestHuhPrompterSelect(t *testing.T) { + tests := []struct { + name string + options []string + defaultValue string + keys []tea.KeyPressMsg // keypresses before Enter + wantIndex int + }{ + { + name: "selects first option by default", + options: []string{"a", "b", "c"}, + wantIndex: 0, + }, + { + name: "respects default value", + options: []string{"a", "b", "c"}, + defaultValue: "b", + wantIndex: 1, + }, + { + name: "invalid default selects first", + options: []string{"a", "b", "c"}, + defaultValue: "z", + wantIndex: 0, + }, + { + name: "navigate down one", + options: []string{"a", "b", "c"}, + keys: []tea.KeyPressMsg{keypress('j')}, + wantIndex: 1, + }, + { + name: "navigate down two", + options: []string{"a", "b", "c"}, + keys: []tea.KeyPressMsg{keypress('j'), keypress('j')}, + wantIndex: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newTestHuhPrompter() + f, result := p.buildSelectForm("Pick:", tt.defaultValue, tt.options) + f.Update(f.Init()) + + var m huh.Model = f + for _, k := range tt.keys { + m = batchUpdate(m.Update(k)) + } + batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) + + require.Equal(t, tt.wantIndex, *result) + }) + } +} + +func TestHuhPrompterMultiSelect(t *testing.T) { + tests := []struct { + name string + options []string + defaults []string + keys []tea.KeyPressMsg + wantResult []int + }{ + { + name: "no defaults and no toggles returns empty", + options: []string{"a", "b", "c"}, + wantResult: []int{}, + }, + { + name: "defaults are pre-selected", + options: []string{"a", "b", "c"}, + defaults: []string{"a", "c"}, + wantResult: []int{0, 2}, + }, + { + name: "toggle first option", + options: []string{"a", "b", "c"}, + keys: []tea.KeyPressMsg{keypress('x')}, + wantResult: []int{0}, + }, + { + name: "toggle multiple options", + options: []string{"a", "b", "c"}, + keys: []tea.KeyPressMsg{ + keypress('x'), // toggle a + keypress('j'), // move to b + keypress('j'), // move to c + keypress('x'), // toggle c + }, + wantResult: []int{0, 2}, + }, + { + name: "invalid defaults are excluded", + options: []string{"a", "b"}, + defaults: []string{"z"}, + wantResult: []int{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newTestHuhPrompter() + f, result := p.buildMultiSelectForm("Pick:", tt.defaults, tt.options) + f.Update(f.Init()) + + var m huh.Model = f + for _, k := range tt.keys { + m = batchUpdate(m.Update(k)) + } + batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) + + require.Equal(t, tt.wantResult, *result) + }) + } +} + +func TestHuhPrompterConfirm(t *testing.T) { + tests := []struct { + name string + defaultValue bool + keys []tea.KeyPressMsg + wantResult bool + }{ + { + name: "default false submitted as-is", + defaultValue: false, + wantResult: false, + }, + { + name: "default true submitted as-is", + defaultValue: true, + wantResult: true, + }, + { + name: "toggle from false to true with left arrow", + defaultValue: false, + keys: []tea.KeyPressMsg{codeKeypress(tea.KeyLeft)}, + wantResult: true, + }, + { + name: "toggle from true to false with right arrow", + defaultValue: true, + keys: []tea.KeyPressMsg{codeKeypress(tea.KeyRight)}, + wantResult: false, + }, + { + name: "accept with y key", + defaultValue: false, + keys: []tea.KeyPressMsg{keypress('y')}, + wantResult: true, + }, + { + name: "reject with n key", + defaultValue: true, + keys: []tea.KeyPressMsg{keypress('n')}, + wantResult: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newTestHuhPrompter() + f, result := p.buildConfirmForm("Sure?", tt.defaultValue) + f.Update(f.Init()) + + var m huh.Model = f + for _, k := range tt.keys { + m = batchUpdate(m.Update(k)) + } + batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) + + require.Equal(t, tt.wantResult, *result) + }) + } +} + +func TestHuhPrompterPassword(t *testing.T) { + tests := []struct { + name string + input string + wantResult string + }{ + { + name: "basic password", + input: "s3cret", + wantResult: "s3cret", + }, + { + name: "empty password", + wantResult: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newTestHuhPrompter() + f, result := p.buildPasswordForm("Password:") + f.Update(f.Init()) + + var m huh.Model = f + if tt.input != "" { + m = typeText(m, tt.input) + } + batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) + + require.Equal(t, tt.wantResult, *result) + }) + } +} + +func TestHuhPrompterMarkdownEditor(t *testing.T) { + tests := []struct { + name string + blankAllowed bool + keys []tea.KeyPressMsg + wantResult string + }{ + { + name: "selects launch by default", + blankAllowed: true, + wantResult: "launch", + }, + { + name: "navigate to skip", + blankAllowed: true, + keys: []tea.KeyPressMsg{keypress('j')}, + wantResult: "skip", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newTestHuhPrompter() + f, result := p.buildMarkdownEditorForm("Body:", tt.blankAllowed) + f.Update(f.Init()) + + var m huh.Model = f + for _, k := range tt.keys { + m = batchUpdate(m.Update(k)) + } + batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) + + require.Equal(t, tt.wantResult, *result) + }) + } +} + +func TestHuhPrompterMultiSelectWithSearch(t *testing.T) { + staticSearchFunc := func(query string) MultiSelectSearchResult { + if query == "" { + return MultiSelectSearchResult{ + Keys: []string{"result-a", "result-b"}, + Labels: []string{"Result A", "Result B"}, + } + } + return MultiSelectSearchResult{ + Keys: []string{"search-1", "search-2"}, + Labels: []string{"Search 1", "Search 2"}, + } + } + + tests := []struct { + name string + defaults []string + persistent []string + keys []tea.KeyPressMsg + wantResult []string + }{ + { + name: "defaults are pre-selected and returned on immediate submit", + defaults: []string{"result-a"}, + keys: []tea.KeyPressMsg{ + // Tab past the search input to the multi-select, then submit. + codeKeypress(tea.KeyTab), + codeKeypress(tea.KeyEnter), + }, + wantResult: []string{"result-a"}, + }, + { + name: "toggle an option from search results", + keys: []tea.KeyPressMsg{ + codeKeypress(tea.KeyTab), // advance to multi-select + keypress('x'), // toggle first option (result-a) + codeKeypress(tea.KeyEnter), // submit + }, + wantResult: []string{"result-a"}, + }, + { + name: "toggle multiple options", + keys: []tea.KeyPressMsg{ + codeKeypress(tea.KeyTab), // advance to multi-select + keypress('x'), // toggle result-a + keypress('j'), // move to result-b + keypress('x'), // toggle result-b + codeKeypress(tea.KeyEnter), // submit + }, + wantResult: []string{"result-a", "result-b"}, + }, + { + name: "no selection returns empty", + keys: []tea.KeyPressMsg{ + codeKeypress(tea.KeyTab), + codeKeypress(tea.KeyEnter), + }, + wantResult: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newTestHuhPrompter() + f, result := p.buildMultiSelectWithSearchForm( + "Select", "Search", tt.defaults, tt.persistent, staticSearchFunc, + ) + doAllUpdates(f, f.Init()) + + for _, k := range tt.keys { + _, cmd := f.Update(k) + doAllUpdates(f, cmd) + } + + assert.Equal(t, tt.wantResult, *result) + }) + } +} + +func TestHuhPrompterMultiSelectWithSearchPersistence(t *testing.T) { + callCount := 0 + staticSearchFunc := func(query string) MultiSelectSearchResult { + callCount++ + if query == "" { + return MultiSelectSearchResult{ + Keys: []string{"result-a", "result-b"}, + Labels: []string{"Result A", "Result B"}, + } + } + return MultiSelectSearchResult{ + Keys: []string{"search-1", "search-2"}, + Labels: []string{"Search 1", "Search 2"}, + } + } + + t.Run("selections persist after changing search query", func(t *testing.T) { + p := newTestHuhPrompter() + f, result := p.buildMultiSelectWithSearchForm( + "Select", "Search", nil, nil, staticSearchFunc, + ) + doAllUpdates(f, f.Init()) + + steps := []tea.KeyPressMsg{ + // Tab to multi-select, toggle result-a. + codeKeypress(tea.KeyTab), + keypress('x'), + // Shift+Tab back to search input, type "foo". + shiftTabKeypress(), + keypress('f'), keypress('o'), keypress('o'), + // Tab back to multi-select — result-a should still be selected. + codeKeypress(tea.KeyTab), + // Submit. + codeKeypress(tea.KeyEnter), + } + + for _, k := range steps { + _, cmd := f.Update(k) + doAllUpdates(f, cmd) + } + + assert.Equal(t, []string{"result-a"}, *result) + }) +}