From 3ab6eacfe087ae78acc9cd34ade2c2c7b535005a Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 15 Jun 2026 09:54:47 -0700 Subject: [PATCH 1/6] fix: render interactive prompts once on Windows by using ux components azd used the archived AlecAivazis/survey library for interactive input, which has an unfixed Windows-specific bug that re-renders a prompt and its answer above the next prompt. Route the terminal path of Prompt, Select, Confirm, and MultiSelect through the in-repo pkg/ux components, which render prompts themselves and do not exhibit the double-render. Non-terminal, no-prompt, and external prompt-client paths are unchanged. Fixes #435 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/input/console.go | 16 +++ cli/azd/pkg/input/console_ux.go | 182 ++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 cli/azd/pkg/input/console_ux.go diff --git a/cli/azd/pkg/input/console.go b/cli/azd/pkg/input/console.go index 69e3738e670..96bf311ffae 100644 --- a/cli/azd/pkg/input/console.go +++ b/cli/azd/pkg/input/console.go @@ -719,6 +719,10 @@ func (c *AskerConsole) Prompt(ctx context.Context, options ConsoleOptions) (stri return response, nil } + if c.isTerminal { + return c.promptUx(ctx, options) + } + err := c.doInteraction(func(c *AskerConsole) error { return c.asker(promptFromOptions(options), &response) }) @@ -781,6 +785,10 @@ func (c *AskerConsole) Select(ctx context.Context, options ConsoleOptions) (int, return res, nil } + if c.isTerminal { + return c.selectUx(ctx, options) + } + surveyOptions := make([]string, len(options.Options)) surveyDefault := options.DefaultValue surveyDefaultAsString, surveyDefaultIsString := surveyDefault.(string) @@ -854,6 +862,10 @@ func (c *AskerConsole) MultiSelect(ctx context.Context, options ConsoleOptions) return response, nil } + if c.isTerminal { + return c.multiSelectUx(ctx, options) + } + surveyOptions := make([]string, len(options.Options)) surveyDefault := options.DefaultValue surveyDefaultAsArr, surveyDefaultIsArr := surveyDefault.([]string) @@ -931,6 +943,10 @@ func (c *AskerConsole) Confirm(ctx context.Context, options ConsoleOptions) (boo } } + if c.isTerminal { + return c.confirmUx(ctx, options) + } + var defaultValue bool if value, ok := options.DefaultValue.(bool); ok { defaultValue = value diff --git a/cli/azd/pkg/input/console_ux.go b/cli/azd/pkg/input/console_ux.go new file mode 100644 index 00000000000..680a2cc0f2a --- /dev/null +++ b/cli/azd/pkg/input/console_ux.go @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package input + +import ( + "context" + "errors" + "fmt" + "slices" + + surveyterm "github.com/AlecAivazis/survey/v2/terminal" + "github.com/azure/azure-dev/cli/azd/pkg/output" + uxlib "github.com/azure/azure-dev/cli/azd/pkg/ux" +) + +// The interactive prompts below are rendered with the in-repo ux package instead +// of AlecAivazis/survey. survey has a Windows-specific double-render bug (the +// prompt and its answer are redrawn above the next prompt) that was never fixed +// upstream and the library is archived. See Azure/azure-dev#435. +// +// Only the terminal path is routed through ux. The non-terminal / no-prompt paths +// continue to use the lightweight asker implementation so machine-friendly input +// behavior (used by tests, CI, and piped stdin) is unchanged. + +// mapUxCancel converts a ux cancellation error into the survey interrupt error +// that the rest of azd already recognizes, preserving existing error handling. +func mapUxCancel(err error) error { + if err != nil && errors.Is(err, uxlib.ErrCancelled) { + return surveyterm.InterruptErr + } + + return err +} + +// optionLabel returns the display label for an option, appending the gray detail +// text (when present) the same way the previous survey-based rendering did. +func optionLabel(options ConsoleOptions, index int) string { + option := options.Options[index] + if index < len(options.OptionDetails) && options.OptionDetails[index] != "" { + return fmt.Sprintf("%s %s", option, output.WithGrayFormat("(%s)", options.OptionDetails[index])) + } + + return option +} + +func (c *AskerConsole) promptUx(ctx context.Context, options ConsoleOptions) (string, error) { + var defaultValue string + if value, ok := options.DefaultValue.(string); ok { + defaultValue = value + } + + prompt := uxlib.NewPrompt(&uxlib.PromptOptions{ + Writer: c.writer, + Message: options.Message, + HelpMessage: options.Help, + DefaultValue: defaultValue, + Secret: options.IsPassword, + }) + + var response string + err := c.doInteraction(func(c *AskerConsole) error { + var askErr error + response, askErr = prompt.Ask(ctx) + return askErr + }) + if err != nil { + return "", mapUxCancel(err) + } + + c.updateLastBytes(afterIoSentinel) + return response, nil +} + +func (c *AskerConsole) selectUx(ctx context.Context, options ConsoleOptions) (int, error) { + choices := make([]*uxlib.SelectChoice, len(options.Options)) + for i, option := range options.Options { + choices[i] = &uxlib.SelectChoice{ + Value: option, + Label: optionLabel(options, i), + } + } + + selectedIndex := 0 + if value, ok := options.DefaultValue.(string); ok { + if idx := slices.Index(options.Options, value); idx >= 0 { + selectedIndex = idx + } + } + + component := uxlib.NewSelect(&uxlib.SelectOptions{ + Writer: c.writer, + Message: options.Message, + HelpMessage: options.Help, + Choices: choices, + SelectedIndex: &selectedIndex, + }) + + var result *int + err := c.doInteraction(func(c *AskerConsole) error { + var askErr error + result, askErr = component.Ask(ctx) + return askErr + }) + if err != nil { + return -1, mapUxCancel(err) + } + if result == nil { + return -1, surveyterm.InterruptErr + } + + c.updateLastBytes(afterIoSentinel) + return *result, nil +} + +func (c *AskerConsole) confirmUx(ctx context.Context, options ConsoleOptions) (bool, error) { + defaultValue := false + if value, ok := options.DefaultValue.(bool); ok { + defaultValue = value + } + + component := uxlib.NewConfirm(&uxlib.ConfirmOptions{ + Writer: c.writer, + Message: options.Message, + HelpMessage: options.Help, + DefaultValue: &defaultValue, + }) + + var result *bool + err := c.doInteraction(func(c *AskerConsole) error { + var askErr error + result, askErr = component.Ask(ctx) + return askErr + }) + if err != nil { + return false, mapUxCancel(err) + } + if result == nil { + return false, surveyterm.InterruptErr + } + + c.updateLastBytes(afterIoSentinel) + return *result, nil +} + +func (c *AskerConsole) multiSelectUx(ctx context.Context, options ConsoleOptions) ([]string, error) { + defaultValues, _ := options.DefaultValue.([]string) + + choices := make([]*uxlib.MultiSelectChoice, len(options.Options)) + for i, option := range options.Options { + choices[i] = &uxlib.MultiSelectChoice{ + Value: option, + Label: optionLabel(options, i), + Selected: slices.Contains(defaultValues, option), + } + } + + component := uxlib.NewMultiSelect(&uxlib.MultiSelectOptions{ + Writer: c.writer, + Message: options.Message, + HelpMessage: options.Help, + Choices: choices, + }) + + var selected []*uxlib.MultiSelectChoice + err := c.doInteraction(func(c *AskerConsole) error { + var askErr error + selected, askErr = component.Ask(ctx) + return askErr + }) + if err != nil { + return nil, mapUxCancel(err) + } + + response := make([]string, len(selected)) + for i, choice := range selected { + response[i] = choice.Value + } + + c.updateLastBytes(afterIoSentinel) + return response, nil +} From fc0980b86a40ce7627e34c062abe6f4679d32766 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 15 Jun 2026 09:55:23 -0700 Subject: [PATCH 2/6] docs: add changelog entry for #8648 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli/azd/CHANGELOG.md b/cli/azd/CHANGELOG.md index 12e527fa89c..12be702dbc4 100644 --- a/cli/azd/CHANGELOG.md +++ b/cli/azd/CHANGELOG.md @@ -8,6 +8,8 @@ ### Bugs Fixed +- [[#8648]](https://github.com/Azure/azure-dev/pull/8648) Fix interactive prompts (for example the `azd init` environment-name prompt) rendering twice on Windows terminals by rendering prompts with azd's own UX components instead of the archived survey library. + ### Other Changes ## 1.25.6 (2026-06-12) From e1f8385a2c564d85a8632779696cc62c9de99d4b Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 15 Jun 2026 09:57:28 -0700 Subject: [PATCH 3/6] docs: update changelog PR reference to #8649 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/CHANGELOG.md b/cli/azd/CHANGELOG.md index 12be702dbc4..8ad6c39765a 100644 --- a/cli/azd/CHANGELOG.md +++ b/cli/azd/CHANGELOG.md @@ -8,7 +8,7 @@ ### Bugs Fixed -- [[#8648]](https://github.com/Azure/azure-dev/pull/8648) Fix interactive prompts (for example the `azd init` environment-name prompt) rendering twice on Windows terminals by rendering prompts with azd's own UX components instead of the archived survey library. +- [[#8649]](https://github.com/Azure/azure-dev/pull/8649) Fix interactive prompts (for example the `azd init` environment-name prompt) rendering twice on Windows terminals by rendering prompts with azd's own UX components instead of the archived survey library. ### Other Changes From 43c234e95b92b1002753a34d608c0409c1a9beab Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 15 Jun 2026 11:23:18 -0700 Subject: [PATCH 4/6] fix: address PR review feedback on ux prompt migration - Guard the ux terminal dispatch with !c.noPrompt so --no-prompt continues to short-circuit through the Asker no-prompt implementation instead of prompting. - Preserve survey's empty-selection behavior by setting AllowEmptySelection on the ux MultiSelect (callers that require a choice already enforce it). - Use new(val) pointer construction per repo Go conventions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/input/console.go | 8 ++++---- cli/azd/pkg/input/console_ux.go | 13 +++++++------ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/cli/azd/pkg/input/console.go b/cli/azd/pkg/input/console.go index 96bf311ffae..6164135a019 100644 --- a/cli/azd/pkg/input/console.go +++ b/cli/azd/pkg/input/console.go @@ -719,7 +719,7 @@ func (c *AskerConsole) Prompt(ctx context.Context, options ConsoleOptions) (stri return response, nil } - if c.isTerminal { + if c.isTerminal && !c.noPrompt { return c.promptUx(ctx, options) } @@ -785,7 +785,7 @@ func (c *AskerConsole) Select(ctx context.Context, options ConsoleOptions) (int, return res, nil } - if c.isTerminal { + if c.isTerminal && !c.noPrompt { return c.selectUx(ctx, options) } @@ -862,7 +862,7 @@ func (c *AskerConsole) MultiSelect(ctx context.Context, options ConsoleOptions) return response, nil } - if c.isTerminal { + if c.isTerminal && !c.noPrompt { return c.multiSelectUx(ctx, options) } @@ -943,7 +943,7 @@ func (c *AskerConsole) Confirm(ctx context.Context, options ConsoleOptions) (boo } } - if c.isTerminal { + if c.isTerminal && !c.noPrompt { return c.confirmUx(ctx, options) } diff --git a/cli/azd/pkg/input/console_ux.go b/cli/azd/pkg/input/console_ux.go index 680a2cc0f2a..bd3a7815954 100644 --- a/cli/azd/pkg/input/console_ux.go +++ b/cli/azd/pkg/input/console_ux.go @@ -93,7 +93,7 @@ func (c *AskerConsole) selectUx(ctx context.Context, options ConsoleOptions) (in Message: options.Message, HelpMessage: options.Help, Choices: choices, - SelectedIndex: &selectedIndex, + SelectedIndex: new(selectedIndex), }) var result *int @@ -123,7 +123,7 @@ func (c *AskerConsole) confirmUx(ctx context.Context, options ConsoleOptions) (b Writer: c.writer, Message: options.Message, HelpMessage: options.Help, - DefaultValue: &defaultValue, + DefaultValue: new(defaultValue), }) var result *bool @@ -156,10 +156,11 @@ func (c *AskerConsole) multiSelectUx(ctx context.Context, options ConsoleOptions } component := uxlib.NewMultiSelect(&uxlib.MultiSelectOptions{ - Writer: c.writer, - Message: options.Message, - HelpMessage: options.Help, - Choices: choices, + Writer: c.writer, + Message: options.Message, + HelpMessage: options.Help, + Choices: choices, + AllowEmptySelection: new(true), }) var selected []*uxlib.MultiSelectChoice From 314e9aae44b3579c12b880ea9d041ea877ba3ee0 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 15 Jun 2026 13:23:58 -0700 Subject: [PATCH 5/6] test: add unit coverage for ux prompt helpers Extract pure transformation helpers (selectChoices, multiSelectChoices, multiSelectValues) from the ux prompt wrappers and add table-driven tests covering them plus mapUxCancel and optionLabel. Restores the pkg/input coverage that dropped when console_ux.go was introduced. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/input/console_ux.go | 84 ++++++++----- cli/azd/pkg/input/console_ux_test.go | 177 +++++++++++++++++++++++++++ 2 files changed, 229 insertions(+), 32 deletions(-) create mode 100644 cli/azd/pkg/input/console_ux_test.go diff --git a/cli/azd/pkg/input/console_ux.go b/cli/azd/pkg/input/console_ux.go index bd3a7815954..cd2e9f31508 100644 --- a/cli/azd/pkg/input/console_ux.go +++ b/cli/azd/pkg/input/console_ux.go @@ -44,6 +44,55 @@ func optionLabel(options ConsoleOptions, index int) string { return option } +// selectChoices builds the ux select choices and the initially selected index +// from the console options. The selected index defaults to 0 and is set to the +// position of the string default value when present. +func selectChoices(options ConsoleOptions) ([]*uxlib.SelectChoice, int) { + choices := make([]*uxlib.SelectChoice, len(options.Options)) + for i, option := range options.Options { + choices[i] = &uxlib.SelectChoice{ + Value: option, + Label: optionLabel(options, i), + } + } + + selectedIndex := 0 + if value, ok := options.DefaultValue.(string); ok { + if idx := slices.Index(options.Options, value); idx >= 0 { + selectedIndex = idx + } + } + + return choices, selectedIndex +} + +// multiSelectChoices builds the ux multi-select choices from the console options, +// marking choices that appear in the []string default value as pre-selected. +func multiSelectChoices(options ConsoleOptions) []*uxlib.MultiSelectChoice { + defaultValues, _ := options.DefaultValue.([]string) + + choices := make([]*uxlib.MultiSelectChoice, len(options.Options)) + for i, option := range options.Options { + choices[i] = &uxlib.MultiSelectChoice{ + Value: option, + Label: optionLabel(options, i), + Selected: slices.Contains(defaultValues, option), + } + } + + return choices +} + +// multiSelectValues maps the ux multi-select result back to the option values. +func multiSelectValues(selected []*uxlib.MultiSelectChoice) []string { + response := make([]string, len(selected)) + for i, choice := range selected { + response[i] = choice.Value + } + + return response +} + func (c *AskerConsole) promptUx(ctx context.Context, options ConsoleOptions) (string, error) { var defaultValue string if value, ok := options.DefaultValue.(string); ok { @@ -73,20 +122,7 @@ func (c *AskerConsole) promptUx(ctx context.Context, options ConsoleOptions) (st } func (c *AskerConsole) selectUx(ctx context.Context, options ConsoleOptions) (int, error) { - choices := make([]*uxlib.SelectChoice, len(options.Options)) - for i, option := range options.Options { - choices[i] = &uxlib.SelectChoice{ - Value: option, - Label: optionLabel(options, i), - } - } - - selectedIndex := 0 - if value, ok := options.DefaultValue.(string); ok { - if idx := slices.Index(options.Options, value); idx >= 0 { - selectedIndex = idx - } - } + choices, selectedIndex := selectChoices(options) component := uxlib.NewSelect(&uxlib.SelectOptions{ Writer: c.writer, @@ -144,22 +180,11 @@ func (c *AskerConsole) confirmUx(ctx context.Context, options ConsoleOptions) (b } func (c *AskerConsole) multiSelectUx(ctx context.Context, options ConsoleOptions) ([]string, error) { - defaultValues, _ := options.DefaultValue.([]string) - - choices := make([]*uxlib.MultiSelectChoice, len(options.Options)) - for i, option := range options.Options { - choices[i] = &uxlib.MultiSelectChoice{ - Value: option, - Label: optionLabel(options, i), - Selected: slices.Contains(defaultValues, option), - } - } - component := uxlib.NewMultiSelect(&uxlib.MultiSelectOptions{ Writer: c.writer, Message: options.Message, HelpMessage: options.Help, - Choices: choices, + Choices: multiSelectChoices(options), AllowEmptySelection: new(true), }) @@ -173,11 +198,6 @@ func (c *AskerConsole) multiSelectUx(ctx context.Context, options ConsoleOptions return nil, mapUxCancel(err) } - response := make([]string, len(selected)) - for i, choice := range selected { - response[i] = choice.Value - } - c.updateLastBytes(afterIoSentinel) - return response, nil + return multiSelectValues(selected), nil } diff --git a/cli/azd/pkg/input/console_ux_test.go b/cli/azd/pkg/input/console_ux_test.go new file mode 100644 index 00000000000..75b939f6bcf --- /dev/null +++ b/cli/azd/pkg/input/console_ux_test.go @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package input + +import ( + "errors" + "fmt" + "testing" + + surveyterm "github.com/AlecAivazis/survey/v2/terminal" + uxlib "github.com/azure/azure-dev/cli/azd/pkg/ux" + "github.com/stretchr/testify/require" +) + +func TestMapUxCancel(t *testing.T) { + t.Run("nil passes through", func(t *testing.T) { + require.NoError(t, mapUxCancel(nil)) + }) + + t.Run("ErrCancelled maps to InterruptErr", func(t *testing.T) { + require.ErrorIs(t, mapUxCancel(uxlib.ErrCancelled), surveyterm.InterruptErr) + }) + + t.Run("wrapped ErrCancelled maps to InterruptErr", func(t *testing.T) { + wrapped := fmt.Errorf("reading input: %w", uxlib.ErrCancelled) + require.ErrorIs(t, mapUxCancel(wrapped), surveyterm.InterruptErr) + }) + + t.Run("other errors pass through unchanged", func(t *testing.T) { + other := errors.New("boom") + require.Equal(t, other, mapUxCancel(other)) + }) +} + +func TestOptionLabel(t *testing.T) { + t.Run("no details returns plain option", func(t *testing.T) { + options := ConsoleOptions{Options: []string{"alpha", "beta"}} + require.Equal(t, "alpha", optionLabel(options, 0)) + require.Equal(t, "beta", optionLabel(options, 1)) + }) + + t.Run("detail is appended", func(t *testing.T) { + options := ConsoleOptions{ + Options: []string{"alpha"}, + OptionDetails: []string{"the first"}, + } + require.Contains(t, optionLabel(options, 0), "alpha") + require.Contains(t, optionLabel(options, 0), "the first") + }) + + t.Run("empty detail is ignored", func(t *testing.T) { + options := ConsoleOptions{ + Options: []string{"alpha", "beta"}, + OptionDetails: []string{"", "second"}, + } + require.Equal(t, "alpha", optionLabel(options, 0)) + require.Contains(t, optionLabel(options, 1), "second") + }) + + t.Run("details shorter than options does not panic", func(t *testing.T) { + options := ConsoleOptions{ + Options: []string{"alpha", "beta"}, + OptionDetails: []string{"only first"}, + } + require.Contains(t, optionLabel(options, 0), "only first") + require.Equal(t, "beta", optionLabel(options, 1)) + }) +} + +func TestSelectChoices(t *testing.T) { + t.Run("maps options to choices with default index 0", func(t *testing.T) { + options := ConsoleOptions{Options: []string{"alpha", "beta", "gamma"}} + choices, selectedIndex := selectChoices(options) + + require.Len(t, choices, 3) + require.Equal(t, "alpha", choices[0].Value) + require.Equal(t, "alpha", choices[0].Label) + require.Equal(t, "gamma", choices[2].Value) + require.Equal(t, 0, selectedIndex) + }) + + t.Run("default value selects matching index", func(t *testing.T) { + options := ConsoleOptions{ + Options: []string{"alpha", "beta", "gamma"}, + DefaultValue: "gamma", + } + _, selectedIndex := selectChoices(options) + require.Equal(t, 2, selectedIndex) + }) + + t.Run("default value not in options falls back to 0", func(t *testing.T) { + options := ConsoleOptions{ + Options: []string{"alpha", "beta"}, + DefaultValue: "missing", + } + _, selectedIndex := selectChoices(options) + require.Equal(t, 0, selectedIndex) + }) + + t.Run("non-string default falls back to 0", func(t *testing.T) { + options := ConsoleOptions{ + Options: []string{"alpha", "beta"}, + DefaultValue: 42, + } + _, selectedIndex := selectChoices(options) + require.Equal(t, 0, selectedIndex) + }) + + t.Run("labels include option details", func(t *testing.T) { + options := ConsoleOptions{ + Options: []string{"alpha"}, + OptionDetails: []string{"detail"}, + } + choices, _ := selectChoices(options) + require.Equal(t, "alpha", choices[0].Value) + require.Contains(t, choices[0].Label, "detail") + }) +} + +func TestMultiSelectChoices(t *testing.T) { + t.Run("maps options with none selected by default", func(t *testing.T) { + options := ConsoleOptions{Options: []string{"alpha", "beta"}} + choices := multiSelectChoices(options) + + require.Len(t, choices, 2) + require.Equal(t, "alpha", choices[0].Value) + require.False(t, choices[0].Selected) + require.False(t, choices[1].Selected) + }) + + t.Run("default values mark choices selected", func(t *testing.T) { + options := ConsoleOptions{ + Options: []string{"alpha", "beta", "gamma"}, + DefaultValue: []string{"alpha", "gamma"}, + } + choices := multiSelectChoices(options) + + require.True(t, choices[0].Selected) + require.False(t, choices[1].Selected) + require.True(t, choices[2].Selected) + }) + + t.Run("non-slice default selects nothing", func(t *testing.T) { + options := ConsoleOptions{ + Options: []string{"alpha", "beta"}, + DefaultValue: "alpha", + } + choices := multiSelectChoices(options) + require.False(t, choices[0].Selected) + require.False(t, choices[1].Selected) + }) + + t.Run("labels include option details", func(t *testing.T) { + options := ConsoleOptions{ + Options: []string{"alpha"}, + OptionDetails: []string{"detail"}, + } + choices := multiSelectChoices(options) + require.Contains(t, choices[0].Label, "detail") + }) +} + +func TestMultiSelectValues(t *testing.T) { + t.Run("maps choices back to values", func(t *testing.T) { + selected := []*uxlib.MultiSelectChoice{ + {Value: "alpha"}, + {Value: "gamma"}, + } + require.Equal(t, []string{"alpha", "gamma"}, multiSelectValues(selected)) + }) + + t.Run("empty selection returns empty slice", func(t *testing.T) { + require.Empty(t, multiSelectValues(nil)) + require.Empty(t, multiSelectValues([]*uxlib.MultiSelectChoice{})) + }) +} From 40f2ab4911bde88c597682896076a50f6d4019a7 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 15 Jun 2026 14:16:12 -0700 Subject: [PATCH 6/6] test: cover ux prompt plumbing via generic runComponent seam Refactor the ux prompt wrappers to delegate to a generic runComponent helper and extract option-builder and result-transform helpers. This collapses the duplicated spinner/cancel/bookkeeping logic into one testable function and makes the per-type wiring unit-testable. Adds tests for runComponent (success, cancel, error, pointer results), the option builders, and the result transforms, raising pkg/input coverage back above its pre-change level. Only the four thin TTY-bound *Ux wrappers remain uncovered (they require a real terminal). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/input/console_ux.go | 143 +++++++++++--------- cli/azd/pkg/input/console_ux_test.go | 191 +++++++++++++++++++++++++++ 2 files changed, 275 insertions(+), 59 deletions(-) diff --git a/cli/azd/pkg/input/console_ux.go b/cli/azd/pkg/input/console_ux.go index cd2e9f31508..917e4dc9d60 100644 --- a/cli/azd/pkg/input/console_ux.go +++ b/cli/azd/pkg/input/console_ux.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "io" "slices" surveyterm "github.com/AlecAivazis/survey/v2/terminal" @@ -93,111 +94,135 @@ func multiSelectValues(selected []*uxlib.MultiSelectChoice) []string { return response } -func (c *AskerConsole) promptUx(ctx context.Context, options ConsoleOptions) (string, error) { +// newPromptOptions builds the ux prompt options from the console options. +func newPromptOptions(writer io.Writer, options ConsoleOptions) *uxlib.PromptOptions { var defaultValue string if value, ok := options.DefaultValue.(string); ok { defaultValue = value } - prompt := uxlib.NewPrompt(&uxlib.PromptOptions{ - Writer: c.writer, + return &uxlib.PromptOptions{ + Writer: writer, Message: options.Message, HelpMessage: options.Help, DefaultValue: defaultValue, Secret: options.IsPassword, - }) - - var response string - err := c.doInteraction(func(c *AskerConsole) error { - var askErr error - response, askErr = prompt.Ask(ctx) - return askErr - }) - if err != nil { - return "", mapUxCancel(err) } - - c.updateLastBytes(afterIoSentinel) - return response, nil } -func (c *AskerConsole) selectUx(ctx context.Context, options ConsoleOptions) (int, error) { +// newSelectOptions builds the ux select options from the console options. +func newSelectOptions(writer io.Writer, options ConsoleOptions) *uxlib.SelectOptions { choices, selectedIndex := selectChoices(options) - component := uxlib.NewSelect(&uxlib.SelectOptions{ - Writer: c.writer, + return &uxlib.SelectOptions{ + Writer: writer, Message: options.Message, HelpMessage: options.Help, Choices: choices, SelectedIndex: new(selectedIndex), - }) - - var result *int - err := c.doInteraction(func(c *AskerConsole) error { - var askErr error - result, askErr = component.Ask(ctx) - return askErr - }) - if err != nil { - return -1, mapUxCancel(err) } - if result == nil { - return -1, surveyterm.InterruptErr - } - - c.updateLastBytes(afterIoSentinel) - return *result, nil } -func (c *AskerConsole) confirmUx(ctx context.Context, options ConsoleOptions) (bool, error) { +// newConfirmOptions builds the ux confirm options from the console options. +func newConfirmOptions(writer io.Writer, options ConsoleOptions) *uxlib.ConfirmOptions { defaultValue := false if value, ok := options.DefaultValue.(bool); ok { defaultValue = value } - component := uxlib.NewConfirm(&uxlib.ConfirmOptions{ - Writer: c.writer, + return &uxlib.ConfirmOptions{ + Writer: writer, Message: options.Message, HelpMessage: options.Help, DefaultValue: new(defaultValue), - }) + } +} - var result *bool - err := c.doInteraction(func(c *AskerConsole) error { - var askErr error - result, askErr = component.Ask(ctx) - return askErr - }) +// newMultiSelectOptions builds the ux multi-select options from the console options. +// Empty selection is allowed to preserve the behavior of the survey-based +// implementation that callers depend on. +func newMultiSelectOptions(writer io.Writer, options ConsoleOptions) *uxlib.MultiSelectOptions { + return &uxlib.MultiSelectOptions{ + Writer: writer, + Message: options.Message, + HelpMessage: options.Help, + Choices: multiSelectChoices(options), + AllowEmptySelection: new(true), + } +} + +// selectResult converts the ux select result into the (index, error) pair the +// console contract expects. A nil result indicates an interrupted prompt. +func selectResult(result *int, err error) (int, error) { + if err != nil { + return -1, err + } + if result == nil { + return -1, surveyterm.InterruptErr + } + + return *result, nil +} + +// confirmResult converts the ux confirm result into the (bool, error) pair the +// console contract expects. A nil result indicates an interrupted prompt. +func confirmResult(result *bool, err error) (bool, error) { if err != nil { - return false, mapUxCancel(err) + return false, err } if result == nil { return false, surveyterm.InterruptErr } - c.updateLastBytes(afterIoSentinel) return *result, nil } -func (c *AskerConsole) multiSelectUx(ctx context.Context, options ConsoleOptions) ([]string, error) { - component := uxlib.NewMultiSelect(&uxlib.MultiSelectOptions{ - Writer: c.writer, - Message: options.Message, - HelpMessage: options.Help, - Choices: multiSelectChoices(options), - AllowEmptySelection: new(true), - }) +// multiSelectResult converts the ux multi-select result into the ([]string, error) +// pair the console contract expects. +func multiSelectResult(selected []*uxlib.MultiSelectChoice, err error) ([]string, error) { + if err != nil { + return nil, err + } - var selected []*uxlib.MultiSelectChoice + return multiSelectValues(selected), nil +} + +// uxComponent is the subset of the ux prompt components used by runComponent. +type uxComponent[T any] interface { + Ask(ctx context.Context) (T, error) +} + +// runComponent executes an interactive ux component, pausing any active spinner, +// mapping cancellation to the survey interrupt error, and recording the +// post-interaction console state on success. +func runComponent[T any](ctx context.Context, c *AskerConsole, component uxComponent[T]) (T, error) { + var result T err := c.doInteraction(func(c *AskerConsole) error { var askErr error - selected, askErr = component.Ask(ctx) + result, askErr = component.Ask(ctx) return askErr }) if err != nil { - return nil, mapUxCancel(err) + var zero T + return zero, mapUxCancel(err) } c.updateLastBytes(afterIoSentinel) - return multiSelectValues(selected), nil + return result, nil +} + +func (c *AskerConsole) promptUx(ctx context.Context, options ConsoleOptions) (string, error) { + return runComponent(ctx, c, uxlib.NewPrompt(newPromptOptions(c.writer, options))) +} + +func (c *AskerConsole) selectUx(ctx context.Context, options ConsoleOptions) (int, error) { + return selectResult(runComponent(ctx, c, uxlib.NewSelect(newSelectOptions(c.writer, options)))) +} + +func (c *AskerConsole) confirmUx(ctx context.Context, options ConsoleOptions) (bool, error) { + return confirmResult(runComponent(ctx, c, uxlib.NewConfirm(newConfirmOptions(c.writer, options)))) +} + +func (c *AskerConsole) multiSelectUx(ctx context.Context, options ConsoleOptions) ([]string, error) { + return multiSelectResult(runComponent(ctx, c, uxlib.NewMultiSelect(newMultiSelectOptions(c.writer, options)))) } diff --git a/cli/azd/pkg/input/console_ux_test.go b/cli/azd/pkg/input/console_ux_test.go index 75b939f6bcf..48b9f3b7b3a 100644 --- a/cli/azd/pkg/input/console_ux_test.go +++ b/cli/azd/pkg/input/console_ux_test.go @@ -4,15 +4,53 @@ package input import ( + "bytes" + "context" "errors" "fmt" + "io" + "strings" "testing" surveyterm "github.com/AlecAivazis/survey/v2/terminal" + "github.com/azure/azure-dev/cli/azd/pkg/output" uxlib "github.com/azure/azure-dev/cli/azd/pkg/ux" "github.com/stretchr/testify/require" ) +// newTestAskerConsole builds a non-terminal AskerConsole suitable for exercising +// the ux helper plumbing (spinner pausing, result bookkeeping) without a TTY. +func newTestAskerConsole(t *testing.T) *AskerConsole { + t.Helper() + + formatter, err := output.NewFormatter(string(output.NoneFormat)) + require.NoError(t, err) + + buf := &bytes.Buffer{} + c := NewConsole( + false, + false, + Writers{Output: buf}, + ConsoleHandles{Stderr: io.Discard, Stdin: strings.NewReader(""), Stdout: buf}, + formatter, + nil, + ) + + asker, ok := c.(*AskerConsole) + require.True(t, ok) + return asker +} + +// fakeUxComponent is a test double for the ux prompt components used by runComponent. +type fakeUxComponent[T any] struct { + result T + err error +} + +func (f fakeUxComponent[T]) Ask(ctx context.Context) (T, error) { + return f.result, f.err +} + func TestMapUxCancel(t *testing.T) { t.Run("nil passes through", func(t *testing.T) { require.NoError(t, mapUxCancel(nil)) @@ -175,3 +213,156 @@ func TestMultiSelectValues(t *testing.T) { require.Empty(t, multiSelectValues([]*uxlib.MultiSelectChoice{})) }) } + +func TestNewPromptOptions(t *testing.T) { + buf := &bytes.Buffer{} + opts := newPromptOptions(buf, ConsoleOptions{ + Message: "message", + Help: "help", + DefaultValue: "default", + IsPassword: true, + }) + + require.Equal(t, buf, opts.Writer) + require.Equal(t, "message", opts.Message) + require.Equal(t, "help", opts.HelpMessage) + require.Equal(t, "default", opts.DefaultValue) + require.True(t, opts.Secret) + + t.Run("non-string default is ignored", func(t *testing.T) { + opts := newPromptOptions(buf, ConsoleOptions{DefaultValue: 42}) + require.Empty(t, opts.DefaultValue) + }) +} + +func TestNewSelectOptions(t *testing.T) { + buf := &bytes.Buffer{} + opts := newSelectOptions(buf, ConsoleOptions{ + Message: "message", + Options: []string{"alpha", "beta", "gamma"}, + DefaultValue: "gamma", + }) + + require.Equal(t, buf, opts.Writer) + require.Equal(t, "message", opts.Message) + require.Len(t, opts.Choices, 3) + require.NotNil(t, opts.SelectedIndex) + require.Equal(t, 2, *opts.SelectedIndex) +} + +func TestNewConfirmOptions(t *testing.T) { + buf := &bytes.Buffer{} + + t.Run("uses bool default", func(t *testing.T) { + opts := newConfirmOptions(buf, ConsoleOptions{Message: "message", DefaultValue: true}) + require.Equal(t, buf, opts.Writer) + require.Equal(t, "message", opts.Message) + require.NotNil(t, opts.DefaultValue) + require.True(t, *opts.DefaultValue) + }) + + t.Run("defaults to false", func(t *testing.T) { + opts := newConfirmOptions(buf, ConsoleOptions{}) + require.NotNil(t, opts.DefaultValue) + require.False(t, *opts.DefaultValue) + }) +} + +func TestNewMultiSelectOptions(t *testing.T) { + buf := &bytes.Buffer{} + opts := newMultiSelectOptions(buf, ConsoleOptions{ + Message: "message", + Options: []string{"alpha", "beta"}, + DefaultValue: []string{"beta"}, + }) + + require.Equal(t, buf, opts.Writer) + require.Equal(t, "message", opts.Message) + require.Len(t, opts.Choices, 2) + require.False(t, opts.Choices[0].Selected) + require.True(t, opts.Choices[1].Selected) + require.NotNil(t, opts.AllowEmptySelection) + require.True(t, *opts.AllowEmptySelection) +} + +func TestSelectResult(t *testing.T) { + t.Run("error passes through", func(t *testing.T) { + boom := errors.New("boom") + _, err := selectResult(nil, boom) + require.ErrorIs(t, err, boom) + }) + + t.Run("nil result is an interrupt", func(t *testing.T) { + got, err := selectResult(nil, nil) + require.ErrorIs(t, err, surveyterm.InterruptErr) + require.Equal(t, -1, got) + }) + + t.Run("returns dereferenced index", func(t *testing.T) { + got, err := selectResult(new(2), nil) + require.NoError(t, err) + require.Equal(t, 2, got) + }) +} + +func TestConfirmResult(t *testing.T) { + t.Run("error passes through", func(t *testing.T) { + boom := errors.New("boom") + _, err := confirmResult(nil, boom) + require.ErrorIs(t, err, boom) + }) + + t.Run("nil result is an interrupt", func(t *testing.T) { + got, err := confirmResult(nil, nil) + require.ErrorIs(t, err, surveyterm.InterruptErr) + require.False(t, got) + }) + + t.Run("returns dereferenced value", func(t *testing.T) { + got, err := confirmResult(new(true), nil) + require.NoError(t, err) + require.True(t, got) + }) +} + +func TestMultiSelectResult(t *testing.T) { + t.Run("error passes through", func(t *testing.T) { + boom := errors.New("boom") + _, err := multiSelectResult(nil, boom) + require.ErrorIs(t, err, boom) + }) + + t.Run("maps selected values", func(t *testing.T) { + got, err := multiSelectResult([]*uxlib.MultiSelectChoice{{Value: "alpha"}, {Value: "beta"}}, nil) + require.NoError(t, err) + require.Equal(t, []string{"alpha", "beta"}, got) + }) +} + +func TestRunComponent(t *testing.T) { + c := newTestAskerConsole(t) + + t.Run("returns result on success", func(t *testing.T) { + got, err := runComponent[string](t.Context(), c, fakeUxComponent[string]{result: "hello"}) + require.NoError(t, err) + require.Equal(t, "hello", got) + }) + + t.Run("maps cancellation to interrupt", func(t *testing.T) { + _, err := runComponent[string](t.Context(), c, fakeUxComponent[string]{err: uxlib.ErrCancelled}) + require.ErrorIs(t, err, surveyterm.InterruptErr) + }) + + t.Run("passes through other errors", func(t *testing.T) { + boom := errors.New("boom") + _, err := runComponent[string](t.Context(), c, fakeUxComponent[string]{err: boom}) + require.ErrorIs(t, err, boom) + }) + + t.Run("supports pointer result types", func(t *testing.T) { + got, err := runComponent[*int](t.Context(), c, fakeUxComponent[*int]{result: new(7)}) + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, 7, *got) + }) +}