test(huh prompter): add table-driven tests for all prompt types

Extract build*Form() methods from each huhPrompter method, separating
form construction from form.Run(). This enables testing the real form
construction code by driving it with direct model updates, adapted
from huh's own test patterns.

Tests cover Input, Select, MultiSelect, Confirm, Password,
MarkdownEditor, and MultiSelectWithSearch including a persistence
test that verifies selections survive across search query changes.

Also fixes a search cache initialization bug where the first
buildOptions("") call would skip the searchFunc due to
cachedSearchQuery defaulting to "".

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kynan Ware 2026-03-19 10:56:46 -06:00 committed by William Martin
parent f294831e7d
commit 86d876fd34
2 changed files with 602 additions and 48 deletions

View file

@ -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
}

View file

@ -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)
})
}