package huh import ( "bytes" "context" "errors" "fmt" "io" "regexp" "strings" "testing" "time" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/xpty" ) const text = "Huh" var pretty = lipgloss.NewStyle(). Width(60). Border(lipgloss.NormalBorder()). MarginTop(1). Padding(1, 3, 1, 2) func TestForm(t *testing.T) { type Taco struct { Shell string Base string Toppings []string } type Order struct { Taco Taco Name string Instructions string Discount bool } var taco Taco order := Order{Taco: taco} f := NewForm( NewGroup( NewSelect[string](). Options(NewOptions("Soft", "Hard")...). Title("Shell?"). Description("Our tortillas are made fresh in-house every day."). Validate(func(t string) error { if t == "Hard" { return fmt.Errorf("we're out of hard shells, sorry") } return nil }). Value(&order.Taco.Shell), NewSelect[string](). Options(NewOptions("Chicken", "Beef", "Fish", "Beans")...). Value(&order.Taco.Base). Title("Base"), ), // Prompt for toppings and special instructions. // The customer can ask for up to 4 toppings. NewGroup( NewMultiSelect[string](). Title("Toppings"). Description("Choose up to 4."). Options( NewOption("Lettuce", "lettuce").Selected(true), NewOption("Tomatoes", "tomatoes").Selected(true), NewOption("Corn", "corn"), NewOption("Salsa", "salsa"), NewOption("Sour Cream", "sour cream"), NewOption("Cheese", "cheese"), ). Validate(func(t []string) error { if len(t) <= 0 { return fmt.Errorf("at least one topping is required") } return nil }). Value(&order.Taco.Toppings). Filterable(true). Limit(4), ), // Gather final details for the order. NewGroup( NewInput(). Value(&order.Name). Title("What's your name?"). Placeholder("Margaret Thatcher"). Description("For when your order is ready."), NewText(). Value(&order.Instructions). Placeholder("Just put it in the mailbox please"). Title("Special Instructions"). Description("Anything we should know?"). CharLimit(400), NewConfirm(). Title("Would you like 15% off?"). Value(&order.Discount). Affirmative("Yes!"). Negative("No."), ), ) f.Update(f.Init()) view := viewModel(f) // // ┃ Shell? // ┃ Our tortillas are made fresh in-house every day. // ┃ > Soft // ┃ Hard // // Base // > Chicken // Beef // Fish // Beans // // ↑ up • ↓ down • / filter • enter select // if !strings.Contains(view, "┃ Shell?") { t.Log(pretty.Render(view)) t.Error("Expected form to contain Shell? title") } if !strings.Contains(view, "Our tortillas are made fresh in-house every day.") { t.Log(pretty.Render(view)) t.Error("Expected form to contain tortilla description") } if !strings.Contains(view, "Base") { t.Log(pretty.Render(view)) t.Error("Expected form to contain Base title") } // Attempt to select hard shell and retrieve error. m := batchUpdate(f.Update(keypress('j'))) m = batchUpdate(m.Update(codeKeypress(tea.KeyTab))) view = viewModel(m) if !strings.Contains(view, "* we're out of hard shells, sorry") { t.Log(pretty.Render(view)) t.Error("Expected form to show out of hard shells error") } // select back the soft shell m = batchUpdate(m.Update(keypress('k'))) m = batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) view = viewModel(m) if !strings.Contains(view, "┃ > Chicken") { t.Log(pretty.Render(view)) t.Fatal("Expected form to continue to base group") } // batchMsg + nextGroup m = batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) view = viewModel(m) // // ┃ Toppings // ┃ Choose up to 4. // ┃ > ✓ Lettuce // ┃ ✓ Tomatoes // ┃ • Corn // ┃ • Salsa // ┃ • Sour Cream // ┃ • Cheese // // x toggle • ↑ up • ↓ down • enter confirm • shift+tab back // if !strings.Contains(view, "Toppings") { t.Log(pretty.Render(view)) t.Fatal("Expected form to show toppings group") } if !strings.Contains(view, "Choose up to 4.") { t.Log(pretty.Render(view)) t.Error("Expected form to show toppings description") } if !strings.Contains(view, "> ✓ Lettuce ") { t.Log(pretty.Render(view)) t.Error("Expected form to preselect lettuce") } if !strings.Contains(view, " ✓ Tomatoes") { t.Log(pretty.Render(view)) t.Error("Expected form to preselect tomatoes") } m = batchUpdate(m.Update(keypress('j'))) m = batchUpdate(m.Update(keypress('j'))) view = viewModel(m) if !strings.Contains(view, "> • Corn") { t.Log(pretty.Render(view)) t.Error("Expected form to change selection to corn") } m = batchUpdate(m.Update(keypress('x'))) view = viewModel(m) if !strings.Contains(view, "> ✓ Corn") { t.Log(pretty.Render(view)) t.Error("Expected form to change selection to corn") } m = batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) view = viewModel(m) if !strings.Contains(view, "What's your name?") { t.Log(pretty.Render(view)) t.Error("Expected form to prompt for name") } if !strings.Contains(view, "Special Instructions") { t.Log(pretty.Render(view)) t.Error("Expected form to prompt for special instructions") } if !strings.Contains(view, "Would you like 15% off?") { t.Log(pretty.Render(view)) t.Error("Expected form to prompt for discount") } // // ┃ What's your name? // ┃ For when your order is ready. // ┃ > Margaret Thatcher // // Special Instructions // Anything we should know? // Just put it in the mailbox please // // Would you like 15% off? // // Yes! No. // // enter next • shift+tab back // typeText(m, "Glen") view = viewModel(m) if !strings.Contains(view, "Glen") { t.Log(pretty.Render(view)) t.Error("Expected form to accept user input") } if order.Taco.Shell != "Soft" { t.Error("Expected order shell to be Soft") } if order.Taco.Base != "Chicken" { t.Error("Expected order shell to be Chicken") } if len(order.Taco.Toppings) != 3 { t.Error("Expected order to have 3 toppings") } if order.Name != "Glen" { t.Error("Expected order name to be Glen") } // TODO: Finish and submit form. } func TestInput(t *testing.T) { field := NewInput() f := NewForm(NewGroup(field)) f.Update(f.Init()) view := viewModel(f) if !strings.Contains(view, ">") { t.Log(pretty.Render(view)) t.Error("Expected field to contain prompt.") } // Type Huh in the form. f = typeText(f, text) view = viewModel(f) if !strings.Contains(view, text) { t.Log(pretty.Render(view)) t.Error("Expected field to contain " + text) } if !strings.Contains(view, "enter submit") { t.Log(pretty.Render(view)) t.Error("Expected field to contain help.") } if field.GetValue() != text { t.Error("Expected field value to be " + text) } } func TestPasteNotDuplicated(t *testing.T) { field := NewInput().Title("Name") f := NewForm(NewGroup(field)) f.Update(f.Init()) f = batchUpdate(f.Update(tea.PasteMsg{Content: "hello"})).(*Form) if field.GetValue() != "hello" { t.Errorf("Expected field value to be %q, got %q (paste was duplicated)", "hello", field.GetValue()) } } func TestInlineInput(t *testing.T) { field := NewInput(). Title("Input "). Prompt(": "). Description("Description"). Inline(true) f := NewForm(NewGroup(field)).WithWidth(40) f.Update(f.Init()) view := viewModel(f) if !strings.Contains(view, "┃ Input Description:") { t.Log(pretty.Render(view)) t.Error("Expected field to contain inline input.") } // Type Huh in the form. f = typeText(f, text) view = viewModel(f) if !strings.Contains(view, text) { t.Log(pretty.Render(view)) t.Error("Expected field to contain " + text) } if !strings.Contains(view, "enter submit") { t.Log(pretty.Render(view)) t.Error("Expected field to contain help.") } if !strings.Contains(view, "┃ Input Description: "+text) { t.Log(pretty.Render(view)) t.Error("Expected field to contain help.") } if field.GetValue() != text { t.Error("Expected field value to be " + text) } } func TestText(t *testing.T) { field := NewText() f := NewForm(NewGroup(field)) f.Update(f.Init()) // Type Huh in the form. f = typeText(f, text) view := viewModel(f) if !strings.Contains(view, text) { t.Log(pretty.Render(view)) t.Error("Expected field to contain " + text) } if !strings.Contains(view, "alt+enter / ctrl+j new line • ctrl+e open editor • enter submit") { t.Log(pretty.Render(view)) t.Error("Expected field to contain help.") } if field.GetValue() != text { t.Error("Expected field value to be " + text) } } func TestTextExternalEditorHidden(t *testing.T) { field := NewText().ExternalEditor(false) f := NewForm(NewGroup(field)) f.Update(f.Init()) // Type Huh in the form. f = typeText(f, text) view := viewModel(f) if !strings.Contains(view, text) { t.Log(pretty.Render(view)) t.Error("Expected field to contain " + text) } if strings.Contains(view, "ctrl+e open editor") { t.Log(pretty.Render(view)) t.Error("Expected field to contain help without ctrl+e.") } if field.GetValue() != text { t.Error("Expected field value to be " + text) } } func TestConfirm(t *testing.T) { field := NewConfirm().Title("Are you sure?") f := NewForm(NewGroup(field)) f.Update(f.Init()) view := viewModel(f) if !strings.Contains(view, "Yes") { t.Log(pretty.Render(view)) t.Error("Expected field to contain Yes.") } if !strings.Contains(view, "No") { t.Log(pretty.Render(view)) t.Error("Expected field to contain No.") } if !strings.Contains(view, "Are you sure?") { t.Log(pretty.Render(view)) t.Error("Expected field to contain Are you sure?.") } if !strings.Contains(view, "←/→ toggle • enter submit") { t.Log(pretty.Render(view)) t.Error("Expected field to contain help.") } if field.GetValue() != false { t.Log(pretty.Render(view)) t.Error("Expected field value to be false") } // Toggle left f.Update(codeKeypress(tea.KeyLeft)) if field.GetValue() != true { t.Error("Expected field value to be true") } // Toggle right f.Update(codeKeypress(tea.KeyRight)) if field.GetValue() != false { t.Error("Expected field value to be false") } } func TestSelect(t *testing.T) { field := NewSelect[string](). Options(NewOptions( "Foo\nLine 2", "Bar\nLine 2", "Baz\nLine 2", "Ban\nLine 2", )...). Title("Which one?") f := NewForm(NewGroup(field)).WithHeight(5) f.Update(f.Init()) view := viewModel(f) if !strings.Contains(view, "Foo") { t.Log(pretty.Render(view)) t.Error("Expected field to contain Foo.") } if !strings.Contains(view, "Which one?") { t.Log(pretty.Render(view)) t.Error("Expected field to contain Which one?.") } if !strings.Contains(view, "> Foo") { t.Log(pretty.Render(view)) t.Error("Expected cursor to be on Foo.") } // Move selection cursor down f = batchUpdate(f.Update(codeKeypress(tea.KeyDown))).(*Form) view = viewModel(f) if got, ok := field.Hovered(); !ok || got != "Bar\nLine 2" { t.Log(pretty.Render(view)) t.Error("Expected cursor to be on Bar.") } if strings.Contains(view, "> Foo") { t.Log(pretty.Render(view)) t.Error("Expected cursor to be on Bar.") } if !strings.Contains(view, "> Bar") { t.Log(pretty.Render(view)) t.Error("Expected cursor to be on Bar.") } if !strings.Contains(view, "↑ up • ↓ down • / filter • enter submit") { t.Log(pretty.Render(view)) t.Error("Expected field to contain help.") } // Submit f.Update(codeKeypress(tea.KeyEnter)) if field.GetValue() != "Bar\nLine 2" { t.Error("Expected field value to be Bar") } } // doAllUpdates updates the form with the given command, then continues updating it with any resultant commands from the update until no more are returned. func doAllUpdates(f *Form, cmd tea.Cmd) { if cmd == nil { return } var cmds []tea.Cmd switch msg := cmd().(type) { case tea.BatchMsg: for _, subcommand := range msg { doAllUpdates(f, subcommand) } return default: _, result := f.Update(msg) cmds = append(cmds, result) } doAllUpdates(f, tea.Batch(cmds...)) } func TestSelectDynamic(t *testing.T) { trigger := "initial" field1 := NewSelect[string](). TitleFunc(func() string { return "field1 title " + trigger }, &trigger). DescriptionFunc(func() string { return "field1 desc " + trigger }, &trigger). OptionsFunc(func() []Option[string] { return []Option[string]{NewOption("field1 opt "+trigger, "field1 opt "+trigger)} }, &trigger) field2 := NewSelect[string](). TitleFunc(func() string { return "field2 title " + trigger }, &trigger). DescriptionFunc(func() string { return "field2 desc " + trigger }, &trigger). OptionsFunc(func() []Option[string] { return []Option[string]{NewOption("field2 opt "+trigger, "field2 opt "+trigger)} }, &trigger) field1.WithHeight(5) field2.WithHeight(5) f := NewForm(NewGroup(field1, field2)).WithHeight(10) doAllUpdates(f, f.Init()) view := viewModel(f) expectedStrings := []string{ "field1 title initial", "field1 desc initial", "field1 opt initial", "field2 title initial", "field2 desc initial", "field2 opt initial", } for _, expected := range expectedStrings { if !strings.Contains(view, expected) { t.Log(pretty.Render(view)) t.Error("Expected view to contain " + expected) } } if field1.GetValue() != "field1 opt initial" { t.Errorf("Expected field1 value to be field1 opt initial but was %s", field1.GetValue()) } if field2.GetValue() != "field2 opt initial" { t.Errorf("Expected field2 value to be field2 opt initial but was %s", field2.GetValue()) } trigger = "updated" _, cmd := f.Update(nil) doAllUpdates(f, cmd) view = viewModel(f) expectedStrings = []string{ "field1 title updated", "field1 desc updated", "field1 opt updated", "field2 title updated", "field2 desc updated", "field2 opt updated", } for _, expected := range expectedStrings { if !strings.Contains(view, expected) { t.Log(pretty.Render(view)) t.Error("Expected view to contain " + expected) } } if field1.GetValue() != "field1 opt updated" { t.Errorf("Expected field1 value to be field1 opt updated but was %s", field1.GetValue()) } if field2.GetValue() != "field2 opt updated" { t.Errorf("Expected field2 value to be field2 opt updated but was %s", field1.GetValue()) } } func TestMultiSelect(t *testing.T) { field := NewMultiSelect[string](). Options(NewOptions( "Foo\nLine2", "Bar\nLine2", "Baz\nLine2", "Ban\nLine2", )...). Title("Which one?") f := NewForm(NewGroup(field)). WithHeight(5) f.Update(f.Init()) view := viewModel(f) if !strings.Contains(view, "Foo") { t.Log(pretty.Render(view)) t.Error("Expected field to contain Foo.") } if !strings.Contains(view, "Which one?") { t.Log(pretty.Render(view)) t.Error("Expected field to contain Which one?.") } if !strings.Contains(view, "> • Foo") { t.Log(pretty.Render(view)) t.Error("Expected cursor to be on Foo.") } // Move selection cursor down m := batchUpdate(f.Update(keypress('j'))) view = viewModel(m) if got, ok := field.Hovered(); !ok || got != "Bar\nLine2" { t.Log(pretty.Render(view)) t.Error("Expected cursor to be on Bar.") } if strings.Contains(view, "> • Foo") { t.Log(pretty.Render(view)) t.Error("Expected cursor to be on Bar.") } if !strings.Contains(view, "> • Bar") { t.Log(pretty.Render(view)) t.Error("Expected cursor to be on Bar.") } // Toggle m = batchUpdate(f.Update(keypress('x'))) view = viewModel(m) if !strings.Contains(view, "> ✓ Bar") { t.Log(pretty.Render(view)) t.Error("Expected cursor to be on Bar.") } if !strings.Contains(view, "x toggle • ↑ up • ↓ down • / filter • enter submit") { t.Log(pretty.Render(view)) t.Error("Expected field to contain help.") } // Submit f.Update(codeKeypress(tea.KeyEnter)) value := field.GetValue() v, ok := value.([]string) if !ok { t.Error("Expected field value to a slice of string") return } if len(v) != 1 { t.Error("Expected field value length to be 1") } else { if v[0] != "Bar\nLine2" { t.Error("Expected first field value to be Bar") } } } func TestMultiSelectFiltering(t *testing.T) { tests := []struct { name string filtering bool }{ {"Filtering off", false}, {"Filtering on", true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { field := NewMultiSelect[string]().Options(NewOptions("Foo", "Bar", "Baz")...).Title("Which one?").Filterable(tc.filtering) f := NewForm(NewGroup(field)) f.Update(f.Init()) // Filter for values starting with a 'B' only. f.Update(keypress('/')) f.Update(keypress('B')) view := viewModel(f) // When we're filtering, the list should change. if tc.filtering && strings.Contains(view, "Foo") { t.Log(pretty.Render(view)) t.Error("Foo should not in filtered list.") } // When we're not filtering, the list shouldn't change. if !tc.filtering && !strings.Contains(view, "Foo") { t.Log(pretty.Render(view)) t.Error("Expected list to contain Foo.") } }) } t.Run("Remove filter option from help menu.", func(t *testing.T) { field := NewMultiSelect[string]().Options(NewOptions("Foo", "Bar", "Baz")...).Title("Which one?").Filterable(false) f := NewForm(NewGroup(field)) f.Update(f.Init()) view := viewModel(f) if strings.Contains(view, "filter") { t.Log(pretty.Render(view)) t.Error("Expected list to hide filtering in help menu.") } }) } func TestMultiSelectLimitEnforcedWhileFiltered(t *testing.T) { // Regression test: the toggle limit guard must inspect the option that is // actually being toggled, not the option sharing the cursor index in the // unfiltered list. With a filter applied the cursor indexes the filtered // slice, so the two diverge and the limit could be exceeded. field := NewMultiSelect[string](). Options(NewOptions("Red", "Green", "Blue")...). Title("Colors"). Limit(1) f := NewForm(NewGroup(field)) f.Update(f.Init()) // Select "Red" (the first option), reaching the limit of one. m := batchUpdate(f.Update(keypress('x'))) // Filter down to "Blue", which lives at index 2 in the unfiltered list but // at index 0 in the filtered list. m = batchUpdate(m.Update(keypress('/'))) for _, r := range "Blue" { m = batchUpdate(m.Update(keypress(r))) } // Apply the filter: filtering stops but the filtered list remains in place. m = batchUpdate(m.Update(codeKeypress(tea.KeyEscape))) // Attempt to toggle "Blue". The limit is already reached, so this must be a // no-op rather than selecting a second option. batchUpdate(m.Update(keypress('x'))) value, ok := field.GetValue().([]string) if !ok { t.Fatalf("expected []string value, got %T", field.GetValue()) } if len(value) != 1 { t.Fatalf("limit of 1 exceeded while filtering: got %d selected options %v", len(value), value) } if value[0] != "Red" { t.Errorf("expected the only selected option to be Red, got %q", value[0]) } } func TestSelectFilteringShowsMatchesAboveTheCursor(t *testing.T) { // Bar and Baz sit at opposite ends of the list, so filtering for "ba" // while the cursor is at the bottom leaves Bar above the visible window. opts := NewOptions("Bar", "Qux", "Quux", "Foo", "Corge", "Grault", "Garply", "Waldo", "Fred", "Baz") field := NewSelect[string]().Options(opts...).Title("Choose") f := NewForm(NewGroup(field)).WithHeight(6) f.Update(f.Init()) // Move to the end of the list, scrolling the viewport away from the top. batchUpdate(f.Update(keypress('G'))) batchUpdate(f.Update(keypress('/'))) batchUpdate(f.Update(keypress('b'))) m := batchUpdate(f.Update(keypress('a'))) view := viewModel(m) for _, want := range []string{"Bar", "Baz"} { if !strings.Contains(view, want) { t.Log(pretty.Render(view)) t.Errorf("filtered list is missing %q, which matches the filter", want) } } } func TestSelectPageNavigation(t *testing.T) { opts := NewOptions( "Qux", "Quux", "Foo", "Bar", "Baz", "Corge", "Grault", "Garply", "Waldo", "Fred", "Plugh", "Xyzzy", "Thud", "Norf", "Blip", "Flob", "Zorp", "Smurf", "Bloop", "Ping", ) reFirst := regexp.MustCompile(`>( •)? Qux`) reLast := regexp.MustCompile(`>( •)? Ping`) reHalfDown := regexp.MustCompile(`>( •)? Baz`) for name, field := range map[string]Field{ "multiselect": NewMultiSelect[string]().Options(opts...).Title("Choose"), "select": NewSelect[string]().Options(opts...).Title("Choose"), } { t.Run(name, func(t *testing.T) { f := NewForm(NewGroup(field)).WithHeight(10) f.Update(f.Init()) view := viewModel(f) if !reFirst.MatchString(view) { t.Log(pretty.Render(view)) t.Errorf("Wrong item selected, should have matched %q (first item)", reFirst.String()) } m := batchUpdate(f.Update(keypress('G'))) // if name == "multiselect" { // mm := field.(*MultiSelect[string]) // t.Logf("AQUI: height=%d offset=%d", mm.viewport.Height(), mm.viewport.YOffset()) // t.Log("LOOK AT THIS SHIT", ansi.Strip(mm.viewport.View())) // } view = viewModel(m) if !reLast.MatchString(view) { t.Log(pretty.Render(view)) t.Errorf("Wrong item selected, should have matched %q (last item)", reLast.String()) } m = batchUpdate(f.Update(keypress('g'))) view = viewModel(m) if !reFirst.MatchString(view) { t.Log(pretty.Render(view)) t.Errorf("Wrong item selected, should have matched %q (first item)", reFirst.String()) } m = batchUpdate(f.Update(tea.KeyPressMsg(tea.Key{Mod: tea.ModCtrl, Code: 'd'}))) view = viewModel(m) if !reHalfDown.MatchString(view) { t.Log(pretty.Render(view)) t.Errorf("Wrong item selected, should have matched %q (half down item)", reHalfDown.String()) } // sends multiple to verify it stays within boundaries for range 10 { m = batchUpdate(f.Update(tea.KeyPressMsg(tea.Key{Mod: tea.ModCtrl, Code: 'u'}))) } view = viewModel(m) if !reFirst.MatchString(view) { t.Log(pretty.Render(view)) t.Errorf("Wrong item selected, should have matched %q (first item)", reFirst.String()) } // verify it stays within boundaries for range 10 { m = batchUpdate(f.Update(tea.KeyPressMsg(tea.Key{Mod: tea.ModCtrl, Code: 'd'}))) } view = viewModel(m) if !reLast.MatchString(view) { t.Log(pretty.Render(view)) t.Errorf("Wrong item selected, should have matched %q (last item)", reLast.String()) } }) } } func TestFile(t *testing.T) { field := NewFilePicker().Title("Which file?") cmd := field.Init() field.Update(cmd()) view := viewModel(field) if !strings.Contains(view, "No file selected") { t.Log(pretty.Render(view)) t.Error("Expected file picker to show no file selected.") } if !strings.Contains(view, "Which file?") { t.Log(pretty.Render(view)) t.Error("Expected file picker to show title.") } } func TestHideGroup(t *testing.T) { f := NewForm( NewGroup(NewNote().Description("Foo")). WithHide(true), NewGroup(NewNote().Description("Bar")), NewGroup(NewNote().Description("Baz")), NewGroup(NewNote().Description("Qux")). WithHideFunc(func() bool { return false }). WithHide(true), ) f = batchUpdate(f, f.NextGroup()).(*Form) if v := f.View(); !strings.Contains(v, "Bar") { t.Log(pretty.Render(v)) t.Error("expected Bar to be visible") } // should have no effect as previous group is hidden f.Update(prevGroup()) if v := f.View(); !strings.Contains(v, "Bar") { t.Log(pretty.Render(v)) t.Error("expected Bar to be visible") } f.Update(nextGroup()) if v := f.View(); !strings.Contains(v, "Baz") { t.Log(pretty.Render(v)) t.Error("expected Baz to be visible") } f.Update(nextGroup()) if v := f.View(); strings.Contains(v, "Qux") { t.Log(pretty.Render(v)) t.Error("expected Qux to be hidden") } if v := f.State; v != StateCompleted { t.Error("should have been completed") } } func TestHideGroupLastAndFirstGroupsNotHidden(t *testing.T) { f := NewForm( NewGroup(NewNote().Description("Bar")), NewGroup(NewNote().Description("Foo")). WithHide(true), NewGroup(NewNote().Description("Baz")), ) f = batchUpdate(f, f.Init()).(*Form) if v := ansi.Strip(f.View()); !strings.Contains(v, "Bar") { t.Log(pretty.Render(v)) t.Error("expected Bar to not be hidden") } // should have no effect as there isn't any f.Update(prevGroup()) if v := f.View(); !strings.Contains(v, "Bar") { t.Log(pretty.Render(v)) t.Error("expected Bar to not be hidden") } f.Update(nextGroup()) if v := ansi.Strip(f.View()); !strings.Contains(v, "Baz") { t.Log(pretty.Render(v)) t.Error("expected Baz to not be hidden") } // should submit the form f.Update(nextGroup()) if v := f.State; v != StateCompleted { t.Error("should have been completed") } } func TestEmptyGroup(t *testing.T) { // fields can all be filtered out before the group is built, e.g. when // every value was already given on the command line. var fields []Field f := NewForm(NewGroup(fields...)) f = batchUpdate(f, f.Init()).(*Form) if v := ansi.Strip(f.View()); strings.TrimSpace(v) != "" { t.Log(pretty.Render(v)) t.Error("expected an empty group to render nothing") } if v := f.GetFocusedField(); v != nil { t.Error("expected no focused field") } f.Update(nextGroup()) if v := f.State; v != StateCompleted { t.Error("should have been completed") } } func TestEmptyForm(t *testing.T) { // a form with no groups at all: nothing to ask, so it is already done. f := NewForm() f = batchUpdate(f, f.Init()).(*Form) if v := ansi.Strip(f.View()); strings.TrimSpace(v) != "" { t.Log(pretty.Render(v)) t.Error("expected an empty form to render nothing") } if v := f.GetFocusedField(); v != nil { t.Error("expected no focused field") } if v := f.KeyBinds(); v != nil { t.Error("expected no keybinds") } if v := f.Errors(); v != nil { t.Error("expected no errors") } if v := f.State; v != StateCompleted { t.Error("should have been completed") } if err := f.Run(); err != nil { t.Error(err) } } func TestEmptyGroupIsSkipped(t *testing.T) { f := NewForm( NewGroup(), NewGroup(NewNote().Description("Bar")), ) f = batchUpdate(f, f.Init()).(*Form) f.Update(nextGroup()) if v := ansi.Strip(f.View()); !strings.Contains(v, "Bar") { t.Log(pretty.Render(v)) t.Error("expected Bar to be visible") } } func TestPrevGroup(t *testing.T) { f := NewForm( NewGroup(NewNote().Description("Bar")), NewGroup(NewNote().Description("Foo")), NewGroup(NewNote().Description("Baz")), ) f = batchUpdate(f, f.Init()).(*Form) f.Update(nextGroup()) f.Update(nextGroup()) f.Update(prevGroup()) f.Update(prevGroup()) if v := ansi.Strip(f.View()); !strings.Contains(v, "Bar") { t.Log(pretty.Render(v)) t.Error("expected Bar to not be hidden") } } func TestNote(t *testing.T) { field := NewNote(). Title("Taco"). Description("How may we take your order?"). Next(true) f := NewForm(NewGroup(field)) f.Update(f.Init()) view := viewModel(f) if !strings.Contains(view, "Taco") { t.Log(view) t.Error("Expected field to contain Taco title.") } if !strings.Contains(view, "order?") { t.Log(view) t.Error("Expected field to contain Taco description.") } if !strings.Contains(view, "Next") { t.Log(view) t.Error("Expected field to contain next button") } const expect = 7 if h := lipgloss.Height(ansi.Strip(view)); h != expect { t.Log(view) t.Errorf("Expected field to have height %d, got %d", expect, h) } if !strings.Contains(view, "enter submit") { t.Log(view) t.Error("Expected field to contain help.") } } func TestDynamicHelp(t *testing.T) { f := NewForm( NewGroup( NewInput().Title("Dynamic Help"), NewInput().Title("Dynamic Help"), NewInput().Title("Dynamic Help"), ), ) f.Update(f.Init()) view := viewModel(f) if !strings.Contains(view, "Dynamic Help") { t.Log(pretty.Render(view)) t.Fatal("Expected help to contain title.") } if strings.Contains(view, "shift+tab") || strings.Contains(view, "submit") { t.Log(pretty.Render(view)) t.Error("Expected help not to contain shift+tab or submit.") } } func TestSkip(t *testing.T) { f := NewForm( NewGroup( NewInput().Title("First"), NewNote().Title("Skipped"), NewNote().Title("Skipped"), NewInput().Title("Second"), ), ).WithWidth(25) f = batchUpdate(f, f.Init()).(*Form) view := viewModel(f) if !strings.Contains(view, "┃ First") { t.Log(pretty.Render(view)) t.Error("Expected first field to be focused") } // next field should skip both of the notes and proceed to the last input. f.Update(NextField()) view = viewModel(f) if strings.Contains(view, "┃ First") { t.Log(pretty.Render(view)) t.Error("Expected first field to be blurred") } if !strings.Contains(view, "┃ Second") { t.Log(pretty.Render(view)) t.Error("Expected second field to be focused") } // previous field should skip both of the notes and focus the first input. f.Update(PrevField()) view = viewModel(f) if strings.Contains(view, "┃ Second") { t.Log(pretty.Render(view)) t.Error("Expected second field to be blurred") } if !strings.Contains(view, "┃ First") { t.Log(pretty.Render(view)) t.Error("Expected first field to be focused") } } func TestTimeout(t *testing.T) { // This test requires a real program, so make sure it doesn't interfere with our test runner. f := formProgram() // Test that the form times out after 1ms and returns a timeout error. err := f.WithTimeout(100 * time.Millisecond).Run() if err == nil || !errors.Is(err, ErrTimeout) { t.Errorf("expected timeout error, got %v", err) } } func TestAbort(t *testing.T) { // This test requires a real program, so make sure it doesn't interfere with our test runner. f := formProgram() // Test that the form aborts without throwing a timeout error when explicitly told to abort. ctx, cancel := context.WithCancel(context.Background()) // Since the context is cancelled, the program should exit immediately. cancel() // Tell the form to abort. f.Update(tea.KeyPressMsg(tea.Key{Mod: tea.ModCtrl, Code: 'c'})) // Run the program. err := f.RunWithContext(ctx) if err == nil || !errors.Is(err, ErrUserAborted) { t.Errorf("expected user aborted error, got %v", err) } } const ( title = "A Title" description = "A Description" ) var titleAndDescTests = map[string]struct { Empty interface{ View() string } EmptyHeight int Title interface{ View() string } Description interface{ View() string } }{ "Group": { NewGroup(NewInput()), 1, // > NewGroup(NewInput()).Title(title), NewGroup(NewInput()).Description(description), }, "Confirm": { NewConfirm(), 1, // yes | no NewConfirm().Title(title), NewConfirm().Description(description), }, "FilePicker": { NewFilePicker(), 1, // "no file selected" NewFilePicker().Title(title), NewFilePicker().Description(description), }, "Input": { NewInput(), 1, // > NewInput().Title(title), NewInput().Description(description), }, "Note": { NewNote(), 1, // | NewNote().Title(title), NewNote().Description(description), }, "Text": { NewText(), 6, // textarea NewText().Title(title), NewText().Description(description), }, "Select": { NewSelect[string](), 1, // > NewSelect[string]().Title(title), NewSelect[string]().Description(description), }, "MultiSelect": { NewMultiSelect[string](), 1, // > NewMultiSelect[string]().Title(title), NewMultiSelect[string]().Description(description), }, } func TestNoTitleOrDescription(t *testing.T) { for name, tt := range titleAndDescTests { t.Run(name, func(t *testing.T) { view := tt.Empty.View() got := lipgloss.Height(ansi.Strip(view)) want := tt.EmptyHeight if got != want { t.Log(pretty.Render(view)) t.Fatalf("got != want; height should be %d, got %d", want, got) } }) } } func TestTitleRowRender(t *testing.T) { for name, tt := range titleAndDescTests { t.Run(name, func(t *testing.T) { view := tt.Title.View() if !strings.Contains(view, title) { t.Log(pretty.Render(view)) t.Error("Expected title to be visible") } }) } } func TestDescriptionRowRender(t *testing.T) { for name, tt := range titleAndDescTests { t.Run(name, func(t *testing.T) { view := tt.Description.View() if !strings.Contains(view, description) { t.Log(pretty.Render(view)) t.Error("Expected description to be visible") } }) } } func TestGetFocusedField(t *testing.T) { f := NewForm( NewGroup( NewInput().Title("First").Key("First"), NewInput().Title("Second").Key("Second"), NewInput().Title("Third").Key("Third"), ), ).WithWidth(25) f = batchUpdate(f, f.Init()).(*Form) f.NextField() field := f.GetFocusedField() if field.GetKey() != "Second" { t.Error("Expected Second field to be focused but was '" + field.GetKey() + "'") } } // formProgram returns a new Form with a nil input and output, so it can be used as a test program. func formProgram() *Form { return NewForm(NewGroup(NewInput().Title("Foo"))). WithInput(nil). WithOutput(io.Discard). WithAccessible(false) } func batchUpdate(m Model, cmd tea.Cmd) 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[T Model](m T, s string) T { var tm Model = m for _, r := range s { tm, _ = tm.Update(keypress(r)) } return tm.(T) } func TestAccessibleForm(t *testing.T) { var out bytes.Buffer f := NewForm( NewGroup( NewInput().Title("Hello:"), ), ). WithAccessible(true). WithOutput(&out). WithInput(strings.NewReader("carlos\n")) if err := f.Run(); err != nil { t.Error(err) } if !strings.Contains(out.String(), "Hello:") { t.Error("invalid output:\n", out.String()) } } func TestAccessibleFields(t *testing.T) { for name, test := range map[string]struct { Field Field FieldFn func() Field Input string CheckOutput func(tb testing.TB, output string) CheckValue func(tb testing.TB, value any) }{ "input": { Field: NewInput(), Input: "Hello", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Input:") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, "Hello", value.(string)) }, }, "input with charlimit": { Field: NewInput().CharLimit(2), Input: "Hello", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Input cannot exceed 2 characters") }, }, "input with default": { FieldFn: func() Field { v := "hi" return NewInput().Value(&v) }, Input: "\n", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Input:") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, "hi", value.(string)) }, }, "confirm": { Field: NewConfirm(), Input: "Y", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Choose [y/N]") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, true, value.(bool)) }, }, "confirm with default": { FieldFn: func() Field { v := true return NewConfirm().Value(&v) }, CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Choose [Y/n]") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, true, value.(bool)) }, }, "confirm with default choose": { FieldFn: func() Field { v := true return NewConfirm().Value(&v) }, Input: "n", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Y/n") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, false, value.(bool)) }, }, "filepicker": { Field: NewFilePicker(), Input: "huh_test.go", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Choose a file:") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, "huh_test.go", value.(string)) }, }, "filepicker with default": { FieldFn: func() Field { v := "huh_test.go" return NewFilePicker().Value(&v) }, Input: "\n", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Choose a file:") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, "huh_test.go", value.(string)) }, }, "multiselect": { Field: NewMultiSelect[string]().Options(NewOptions("a", "b")...), Input: "2", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "2. ✓ b") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() got := value.([]string) requireEqual(tb, 1, len(got)) requireEqual(tb, "b", got[0]) }, }, "multiselect default value": { FieldFn: func() Field { v := []string{"b", "c"} return NewMultiSelect[string]().Options(NewOptions("a", "b", "c", "d")...).Value(&v) }, Input: "\n", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "2. ✓ b") requireContains(tb, output, "3. ✓ c") }, }, "select": { Field: NewSelect[string]().Options(NewOptions("a", "b")...), Input: "2", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Select:") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, "b", value.(string)) }, }, "select default value": { FieldFn: func() Field { v := "c" return NewSelect[string](). Options(NewOptions("a", "b", "c", "d")...). Value(&v) }, Input: "\n", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Select:") requireContains(tb, output, "Enter a number between 1 and 4") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, "c", value.(string)) }, }, "select no input": { Field: NewSelect[string]().Options(NewOptions("a", "b")...), Input: "\n2\n", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Select:") requireContains(tb, output, "Enter a number between 1 and 2") requireContains(tb, output, "Invalid: must be a number between 1 and 2") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, "b", value.(string)) }, }, "select single option": { Field: NewSelect[string]().Options(NewOptions("a")...), Input: "\n1\n", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Select:") requireContains(tb, output, "There is only one option available; enter the number 1:") requireContains(tb, output, "Invalid: must be 1") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, "a", value.(string)) }, }, "note": { Field: NewNote().Title("Hi").Description("there"), CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Hi") requireContains(tb, output, "there") }, }, "text": { Field: NewText().Title("Text:"), Input: "hello world", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Text:") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, "hello world", value.(string)) }, }, "text with limit": { Field: NewText().CharLimit(2).Title("Text"), Input: "hello world", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Input cannot exceed 2 characters") }, }, "text default value": { FieldFn: func() Field { v := "test" return NewText().Title("Text:").Value(&v) }, Input: "\n", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Text:") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, "test", value.(string)) }, }, } { t.Run(name, func(t *testing.T) { field := test.Field if test.FieldFn != nil { field = test.FieldFn() } var out bytes.Buffer if err := field.RunAccessible( &out, strings.NewReader(test.Input), ); err != nil { t.Error(err) } if test.CheckOutput != nil { test.CheckOutput(t, out.String()) } if test.CheckValue != nil { test.CheckValue(t, field.GetValue()) } }) } } func TestInputPasswordAccessible(t *testing.T) { t.Run("not a tty", func(t *testing.T) { var out bytes.Buffer if err := NewInput(). EchoMode(EchoModeNone). RunAccessible(&out, bytes.NewReader(nil)); err == nil { t.Error("expected it to error") } if err := NewInput(). EchoMode(EchoModePassword). RunAccessible(&out, bytes.NewReader(nil)); err == nil { t.Error("expected it to error") } }) t.Run("is a tty", func(t *testing.T) { var out bytes.Buffer pty, err := xpty.NewPty(50, 30) if err != nil { t.Skipf("could not open pty: %v", err) } upty, ok := pty.(*xpty.UnixPty) if !ok { t.Skipf("test only works on unix") } input := NewInput().EchoMode(EchoModePassword) errs := make(chan error, 1) go func() { errs <- input.RunAccessible(&out, upty.Slave()) }() _, _ = upty.Master().Write([]byte("a password\n")) if err := <-errs; err != nil { t.Errorf("expected no error, got %v", err) } t.Logf("%q", out.String()) requireContains(t, out.String(), "Password:") requireEqual(t, "a password", input.GetValue().(string)) }) } func requireEqual[T comparable](tb testing.TB, a, b T) { tb.Helper() if a != b { tb.Fatalf("expected %v to be equal to %v", a, b) } } func requireContains(tb testing.TB, s, subtr string) { tb.Helper() if !strings.Contains(s, subtr) { tb.Fatalf("%q does not contain %q", s, subtr) } } func viewModel(m Model) string { return ansi.Strip(m.View()) }