diff --git a/pkg/cli/prompt/text/text.go b/pkg/cli/prompt/text/text.go index 72ea5532018..82a571ddf7a 100644 --- a/pkg/cli/prompt/text/text.go +++ b/pkg/cli/prompt/text/text.go @@ -24,10 +24,6 @@ import ( "github.com/charmbracelet/lipgloss" ) -type ( - errMsg error -) - var ( QuitTextStyle = lipgloss.NewStyle().Margin(1, 0, 2, 4) ) @@ -59,17 +55,20 @@ type Model struct { prompt string textInput textinput.Model valueEntered bool - err error } // NewTextModel returns a new text model with prompt message. func NewTextModel(prompt string, options TextModelOptions) Model { + // Note: we don't use the validation support provided by textinput due to a bug in the library. + // + // See: https://github.com/charmbracelet/bubbles/issues/244 + // + // This blocks the input control from **ever** containing invalid data. For example `prod-` is an invalid environment name, + // so it will be blocked. This means you can't type `prod-aws` which is a valid name. ti := textinput.New() ti.Focus() ti.Width = 40 - ti.Placeholder = options.Placeholder - ti.Validate = options.Validate return Model{ Style: lipgloss.NewStyle(), // No border or padding by default @@ -93,19 +92,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.Type { case tea.KeyEnter: + // Block submit on invalid values. + if m.textInput.Err != nil { + return m, nil + } + m.valueEntered = true return m, tea.Quit case tea.KeyCtrlC: m.Quitting = true return m, tea.Quit } - - // We handle errors just like any other message - case errMsg: - m.err = msg - return m, nil } + + // Workaround for https://github.com/charmbracelet/bubbles/issues/244 + // + // Instead of using the validation support on textinput, we perform the validation + // and update its state manually. m.textInput, cmd = m.textInput.Update(msg) + if m.options.Validate != nil { + validationErr := m.options.Validate(m.textInput.Value()) + m.textInput.Err = validationErr + } + return m, cmd } diff --git a/pkg/cli/prompt/text/text_test.go b/pkg/cli/prompt/text/text_test.go new file mode 100644 index 00000000000..1968071d3f5 --- /dev/null +++ b/pkg/cli/prompt/text/text_test.go @@ -0,0 +1,160 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package text + +import ( + "io" + "strings" + "testing" + "time" + + "github.com/acarl005/stripansi" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/exp/teatest" + "github.com/stretchr/testify/require" +) + +var ( + // Set this to a big value when debugging. + waitTimeout = 1 * time.Second +) + +func Test_NewTextModel(t *testing.T) { + options := TextModelOptions{ + Default: "test default", + Placeholder: "test placeholder", + Validate: func(input string) error { + return nil + }, + } + model := NewTextModel("test prompt", options) + require.NotNil(t, model) + require.NotNil(t, model.textInput) + + require.Equal(t, "test prompt", model.prompt) + require.Equal(t, options.Placeholder, model.textInput.Placeholder) + require.Nil(t, model.textInput.Validate) // See comments in NewTextModel. +} + +func Test_E2E(t *testing.T) { + // Note: unfortunately I ran into bugs with the testing framework while trying to test more advance + // scenarios like validation. The output coming from the framework was truncated, so I just couldn't do it :(. + // + // At the time of writing the test framework is new and unsupported. We should try again when its more mature. + + normalizeOutput := func(bts []byte) string { + return stripansi.Strip(strings.ReplaceAll(string(bts), "\r\n", "\n")) + } + waitForContains := func(t *testing.T, reader io.Reader, target string) string { + normalized := "" + teatest.WaitFor(t, reader, func(bts []byte) bool { + normalized = normalizeOutput(bts) + t.Logf("Testing output:\n\n%s", normalized) + return strings.Contains(normalized, target) + }, teatest.WithDuration(waitTimeout)) + + return normalized + } + waitForInitialRender := func(t *testing.T, reader io.Reader) string { + return waitForContains(t, reader, ">") + } + + t.Run("confirm default", func(t *testing.T) { + options := TextModelOptions{ + Default: "test default", + Placeholder: "test placeholder", + } + model := NewTextModel("test prompt", options) + tm := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(80, 50)) + output := waitForInitialRender(t, tm.Output()) + + expected := "test prompt\n" + + "\n" + + "> test placeholder\n" + + "\n" + + "(ctrl+c to quit)" + require.Equal(t, expected, output) + + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) + tm.WaitFinished(t, teatest.WithFinalTimeout(waitTimeout)) + bts, err := io.ReadAll(tm.FinalOutput(t)) + require.NoError(t, err) + + output = normalizeOutput(bts) + require.Empty(t, strings.TrimSpace(output)) // Output sometimes contains a single space. + require.True(t, tm.FinalModel(t).(Model).valueEntered) + require.False(t, tm.FinalModel(t).(Model).Quitting) + require.Equal(t, "test default", tm.FinalModel(t).(Model).GetValue()) + }) + + t.Run("confirm value", func(t *testing.T) { + options := TextModelOptions{ + Default: "test default", + Placeholder: "test placeholder", + } + model := NewTextModel("test prompt", options) + tm := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(80, 50)) + output := waitForInitialRender(t, tm.Output()) + + expected := "test prompt\n" + + "\n" + + "> test placeholder\n" + + "\n" + + "(ctrl+c to quit)" + require.Equal(t, expected, output) + + tm.Type("abcd") + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) + tm.WaitFinished(t, teatest.WithFinalTimeout(waitTimeout)) + bts, err := io.ReadAll(tm.FinalOutput(t)) + require.NoError(t, err) + + output = normalizeOutput(bts) + require.Empty(t, strings.TrimSpace(output)) // Output sometimes contains a single space. + require.True(t, tm.FinalModel(t).(Model).valueEntered) + require.False(t, tm.FinalModel(t).(Model).Quitting) + require.Equal(t, "abcd", tm.FinalModel(t).(Model).GetValue()) + }) + + t.Run("cancel", func(t *testing.T) { + options := TextModelOptions{ + Default: "test default", + Placeholder: "test placeholder", + } + model := NewTextModel("test prompt", options) + tm := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(80, 50)) + output := waitForInitialRender(t, tm.Output()) + + expected := "test prompt\n" + + "\n" + + "> test placeholder\n" + + "\n" + + "(ctrl+c to quit)" + require.Equal(t, expected, output) + + tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC}) + tm.WaitFinished(t, teatest.WithFinalTimeout(waitTimeout)) + bts, err := io.ReadAll(tm.FinalOutput(t)) + require.NoError(t, err) + + output = normalizeOutput(bts) + require.Empty(t, strings.TrimSpace(output)) // Output sometimes contains a single space. + require.False(t, tm.FinalModel(t).(Model).valueEntered) + require.True(t, tm.FinalModel(t).(Model).Quitting) + require.Equal(t, "test default", tm.FinalModel(t).(Model).GetValue()) + }) +}