package prompter import ( "io" "testing" "time" "charm.land/huh/v2" "github.com/AlecAivazis/survey/v2/terminal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // --- Interaction helpers --- // A set of helpers for simulating user input in huh form tests. // Each helper (tab(), toggle(), typeKeys(), etc.) produces raw terminal // bytes that are piped into form.Run() via io.Pipe, driving the real // bubbletea event loop. type interactionStep struct { bytes []byte delay time.Duration // pause before sending (lets the event loop settle) } type interaction struct { steps []interactionStep } func newInteraction(steps ...interactionStep) interaction { return interaction{steps: steps} } func (ix interaction) run(t *testing.T, w *io.PipeWriter) { t.Helper() for _, s := range ix.steps { time.Sleep(s.delay) _, err := w.Write(s.bytes) require.NoError(t, err) } } // Step helpers — each returns a single interactionStep. // // These send raw terminal escape sequences that bubbletea's input parser // understands. Common ANSI escape codes: // // \t = Tab // \x1b[Z = Shift+Tab (reverse tab) // \r = Enter (carriage return) // \x1b[A = Arrow Up // \x1b[B = Arrow Down // \x1b[C = Arrow Right // \x1b[D = Arrow Left // \x01 = Ctrl+A (line start) // \x0b = Ctrl+K (kill to end of line) func tab() interactionStep { return interactionStep{bytes: []byte("\t")} } func shiftTab() interactionStep { return interactionStep{bytes: []byte("\x1b[Z")} } func enter() interactionStep { return interactionStep{bytes: []byte("\r")} } func toggle() interactionStep { return interactionStep{bytes: []byte("x")} } func down() interactionStep { return interactionStep{bytes: []byte("\x1b[B")} } func left() interactionStep { return interactionStep{bytes: []byte("\x1b[D")} } func right() interactionStep { return interactionStep{bytes: []byte("\x1b[C")} } func typeKeys(s string) interactionStep { return interactionStep{bytes: []byte(s)} } func pressY() interactionStep { return interactionStep{bytes: []byte("y")} } func pressN() interactionStep { return interactionStep{bytes: []byte("n")} } func clearLine() interactionStep { return interactionStep{bytes: []byte{0x01, 0x0b}} } // waitForOptions adds extra delay to let OptionsFunc load before continuing. func waitForOptions() interactionStep { return interactionStep{bytes: nil, delay: 50 * time.Millisecond} } // --- Test harness --- func newTestHuhPrompter() *huhPrompter { return &huhPrompter{} } // runForm runs a huh form with the given interaction, returning any error. // The form runs in a goroutine using bubbletea's real event loop via io.Pipe. func runForm(t *testing.T, f *huh.Form, ix interaction) { t.Helper() r, w := io.Pipe() f.WithInput(r).WithOutput(io.Discard).WithWidth(80) errCh := make(chan error, 1) go func() { errCh <- f.Run() }() ix.run(t, w) select { case err := <-errCh: require.NoError(t, err) case <-time.After(5 * time.Second): t.Fatal("form.Run() did not complete in time") } } // --- Tests --- func TestHuhPrompterInput(t *testing.T) { tests := []struct { name string defaultValue string ix interaction wantResult string }{ { name: "basic input", ix: newInteraction(typeKeys("hello"), enter()), wantResult: "hello", }, { name: "default value returned when no input", defaultValue: "default", ix: newInteraction(enter()), wantResult: "default", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := newTestHuhPrompter() f, result := p.buildInputForm("Name:", tt.defaultValue) runForm(t, f, tt.ix) require.Equal(t, tt.wantResult, *result) }) } } func TestHuhPrompterSelect(t *testing.T) { tests := []struct { name string options []string defaultValue string ix interaction wantIndex int }{ { name: "selects first option by default", options: []string{"a", "b", "c"}, ix: newInteraction(enter()), wantIndex: 0, }, { name: "respects default value", options: []string{"a", "b", "c"}, defaultValue: "b", ix: newInteraction(enter()), wantIndex: 1, }, { name: "invalid default selects first", options: []string{"a", "b", "c"}, defaultValue: "z", ix: newInteraction(enter()), wantIndex: 0, }, { name: "navigate down one", options: []string{"a", "b", "c"}, ix: newInteraction(down(), enter()), wantIndex: 1, }, { name: "navigate down two", options: []string{"a", "b", "c"}, ix: newInteraction(down(), down(), enter()), 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) runForm(t, f, tt.ix) require.Equal(t, tt.wantIndex, *result) }) } } func TestHuhPrompterMultiSelect(t *testing.T) { tests := []struct { name string options []string defaults []string ix interaction wantResult []int }{ { name: "no defaults and no toggles returns empty", options: []string{"a", "b", "c"}, ix: newInteraction(enter()), wantResult: []int{}, }, { name: "defaults are pre-selected", options: []string{"a", "b", "c"}, defaults: []string{"a", "c"}, ix: newInteraction(enter()), wantResult: []int{0, 2}, }, { name: "toggle first option", options: []string{"a", "b", "c"}, ix: newInteraction(toggle(), enter()), wantResult: []int{0}, }, { name: "toggle multiple options", options: []string{"a", "b", "c"}, ix: newInteraction( toggle(), // toggle a down(), // move to b down(), // move to c toggle(), // toggle c enter(), ), wantResult: []int{0, 2}, }, { name: "invalid defaults are excluded", options: []string{"a", "b"}, defaults: []string{"z"}, ix: newInteraction(enter()), 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) runForm(t, f, tt.ix) require.Equal(t, tt.wantResult, *result) }) } } func TestHuhPrompterConfirm(t *testing.T) { tests := []struct { name string defaultValue bool ix interaction wantResult bool }{ { name: "default false submitted as-is", ix: newInteraction(enter()), wantResult: false, }, { name: "default true submitted as-is", defaultValue: true, ix: newInteraction(enter()), wantResult: true, }, { name: "toggle from false to true with left arrow", ix: newInteraction(left(), enter()), wantResult: true, }, { name: "toggle from true to false with right arrow", defaultValue: true, ix: newInteraction(right(), enter()), wantResult: false, }, { name: "accept with y key", ix: newInteraction(pressY(), enter()), wantResult: true, }, { name: "reject with n key", defaultValue: true, ix: newInteraction(pressN(), enter()), wantResult: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := newTestHuhPrompter() f, result := p.buildConfirmForm("Sure?", tt.defaultValue) runForm(t, f, tt.ix) require.Equal(t, tt.wantResult, *result) }) } } func TestHuhPrompterPassword(t *testing.T) { tests := []struct { name string ix interaction wantResult string }{ { name: "basic password", ix: newInteraction(typeKeys("s3cret"), enter()), wantResult: "s3cret", }, { name: "empty password", ix: newInteraction(enter()), wantResult: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := newTestHuhPrompter() f, result := p.buildPasswordForm("Password:") runForm(t, f, tt.ix) require.Equal(t, tt.wantResult, *result) }) } } func TestHuhPrompterMarkdownEditor(t *testing.T) { tests := []struct { name string blankAllowed bool ix interaction wantResult string }{ { name: "selects launch by default", blankAllowed: true, ix: newInteraction(enter()), wantResult: "launch", }, { name: "navigate to skip", blankAllowed: true, ix: newInteraction(down(), enter()), wantResult: "skip", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := newTestHuhPrompter() f, result := p.buildMarkdownEditorForm("Body:", tt.blankAllowed) runForm(t, f, tt.ix) 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 ix interaction wantResult []string }{ { name: "defaults are pre-selected and returned on immediate submit", defaults: []string{"result-a"}, ix: newInteraction(tab(), enter()), wantResult: []string{"result-a"}, }, { name: "toggle an option from search results", ix: newInteraction(tab(), waitForOptions(), toggle(), enter()), wantResult: []string{"result-a"}, }, { name: "toggle multiple options", ix: newInteraction( tab(), waitForOptions(), toggle(), // toggle result-a down(), // move to result-b toggle(), // toggle result-b enter(), ), wantResult: []string{"result-a", "result-b"}, }, { name: "no selection returns empty", ix: newInteraction(tab(), enter()), wantResult: []string{}, }, { name: "persistent options are shown and selectable", persistent: []string{"persistent-1"}, ix: newInteraction( tab(), waitForOptions(), down(), // skip result-a down(), // skip result-b toggle(), // toggle persistent-1 enter(), ), wantResult: []string{"persistent-1"}, }, } 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, ) runForm(t, f, tt.ix) assert.Equal(t, tt.wantResult, result.selectedKeys()) }) } } func TestHuhPrompterMultiSelectWithSearchPersistence(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"}, } } t.Run("selections persist after changing search query", func(t *testing.T) { p := newTestHuhPrompter() f, result := p.buildMultiSelectWithSearchForm( "Select", "Search", nil, nil, staticSearchFunc, ) runForm(t, f, newInteraction( tab(), waitForOptions(), toggle(), // toggle result-a shiftTab(), // back to search input typeKeys("foo"), // change query tab(), waitForOptions(), enter(), // submit — result-a should persist )) assert.Equal(t, []string{"result-a"}, result.selectedKeys()) }) t.Run("empty search results shows no-results placeholder", func(t *testing.T) { emptySearchFunc := func(query string) MultiSelectSearchResult { return MultiSelectSearchResult{} } p := newTestHuhPrompter() f, result := p.buildMultiSelectWithSearchForm( "Select", "Search", nil, nil, emptySearchFunc, ) // With no results, the "No results" message is shown. // Toggle does nothing, submitting returns empty. runForm(t, f, newInteraction(tab(), waitForOptions(), toggle(), enter())) assert.Equal(t, []string{}, result.selectedKeys()) }) } func TestHuhPrompterAuthToken(t *testing.T) { tests := []struct { name string ix interaction wantResult string }{ { name: "accepts token input", ix: newInteraction(typeKeys("ghp_abc123"), enter()), wantResult: "ghp_abc123", }, { name: "rejects blank then accepts valid input", ix: newInteraction(enter(), typeKeys("ghp_valid"), enter()), wantResult: "ghp_valid", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := newTestHuhPrompter() f, result := p.buildAuthTokenForm() runForm(t, f, tt.ix) require.Equal(t, tt.wantResult, *result) }) } } func TestHuhPrompterConfirmDeletion(t *testing.T) { tests := []struct { name string requiredValue string ix interaction }{ { name: "accepts matching input", requiredValue: "my-repo", ix: newInteraction(typeKeys("my-repo"), enter()), }, { name: "rejects wrong input then accepts correct input", requiredValue: "my-repo", ix: newInteraction(typeKeys("wrong"), enter(), clearLine(), typeKeys("my-repo"), enter()), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := newTestHuhPrompter() f := p.buildConfirmDeletionForm(tt.requiredValue) runForm(t, f, tt.ix) }) } } func TestHuhPrompterInputHostname(t *testing.T) { tests := []struct { name string ix interaction wantResult string }{ { name: "accepts valid hostname", ix: newInteraction(typeKeys("github.example.com"), enter()), wantResult: "github.example.com", }, { name: "rejects blank then accepts valid hostname", ix: newInteraction(enter(), typeKeys("github.example.com"), enter()), wantResult: "github.example.com", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := newTestHuhPrompter() f, result := p.buildInputHostnameForm() runForm(t, f, tt.ix) require.Equal(t, tt.wantResult, *result) }) } } func TestHuhPrompterMultiSelectWithSearchBackspace(t *testing.T) { // Simulate real API latency and non-overlapping results. staticSearchFunc := func(query string) MultiSelectSearchResult { time.Sleep(100 * time.Millisecond) // simulate API latency if query == "" { return MultiSelectSearchResult{ Keys: []string{"alice", "bob"}, Labels: []string{"Alice", "Bob"}, } } return MultiSelectSearchResult{ Keys: []string{"frank", "fiona"}, Labels: []string{"Frank", "Fiona"}, } } t.Run("selections persist after backspacing search query", func(t *testing.T) { p := newTestHuhPrompter() f, result := p.buildMultiSelectWithSearchForm( "Select", "Search", nil, nil, staticSearchFunc, ) longWait := interactionStep{delay: 300 * time.Millisecond} runForm(t, f, newInteraction( tab(), longWait, toggle(), // toggle alice shiftTab(), // back to search input typeKeys("f"), // type "f" longWait, // wait for API + OptionsFunc typeKeys("\x7f"), // backspace to "" longWait, // wait for cache/API tab(), longWait, enter(), )) assert.Equal(t, []string{"alice"}, result.selectedKeys()) }) } func TestRunFormTranslatesErrUserAborted(t *testing.T) { p := newTestHuhPrompter() form, _ := p.buildSelectForm("Pick one:", "", []string{"a", "b", "c"}) r, w := io.Pipe() form.WithInput(r).WithOutput(io.Discard).WithWidth(80) errCh := make(chan error, 1) go func() { errCh <- p.runForm(form) }() // Send Ctrl+C to trigger huh.ErrUserAborted _, err := w.Write([]byte{0x03}) require.NoError(t, err) select { case err := <-errCh: assert.ErrorIs(t, err, terminal.InterruptErr, "expected huh.ErrUserAborted to be translated to terminal.InterruptErr") case <-time.After(5 * time.Second): t.Fatal("runForm did not complete in time") } }