mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
feat(terminal): add confirmation defaults and fix form input loss
Every confirmation defaulted to No, so Enter after a filled-in form canceled it; prompts were titled with raw flag names and aborted on an empty answer; and the shared issue and pull request form lost input: - Multi-selects hid their last option, so a single assignee looked like an empty list and labels showed four of five. They now get an explicit height and scroll past eight options. - A pty without a window size, such as `script` run without a terminal, made bubbles panic. Forms now refuse it with "terminal size unknown; pass the values as flags or use --no-input". - Without a configured editor, the form ran to the end and then failed with "configure an editor", discarding everything typed. The editor is now resolved before the form; without one the body is typed into an inline multi-line field. - The pull request form said "Issue title" and "Issue description". The shared fields read "Title (required):" and "Body (markdown):", and the review body label gained its missing space. New prompt API for the area commands: confirmAction with a terminal.Confirmation (DefaultYes for constructive actions, Typed for type-to-confirm, NonTTY to name the action in the --yes error) and requireInput with a human label and inline validation. Declined or aborted prompts return terminal.ErrCanceled (exit code 3). The existing confirm and require helpers keep working on top of them, and prompters without the new terminal.Asker methods keep the plain behavior. Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
14 files changed
+539
-32
No files matched your search
@@ -5,6 +5,7 @@ go 1.27.0
|
|||||||
toolchain go1.27.1
|
toolchain go1.27.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
charm.land/bubbletea/v2 v2.0.6
|
||||||
charm.land/glamour/v2 v2.0.1
|
charm.land/glamour/v2 v2.0.1
|
||||||
charm.land/huh/v2 v2.0.3
|
charm.land/huh/v2 v2.0.3
|
||||||
code.gitea.io/gitea-vet v0.2.3
|
code.gitea.io/gitea-vet v0.2.3
|
||||||
@@ -30,7 +31,6 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
charm.land/bubbles/v2 v2.1.0 // indirect
|
charm.land/bubbles/v2 v2.1.0 // indirect
|
||||||
charm.land/bubbletea/v2 v2.0.6 // indirect
|
|
||||||
charm.land/lipgloss/v2 v2.0.5 // indirect
|
charm.land/lipgloss/v2 v2.0.5 // indirect
|
||||||
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
|||||||
@@ -49,7 +49,17 @@ func (a *application) editDraft(cmd *cobra.Command, body string) (terminal.Draft
|
|||||||
return editor.Edit(cmd.Context(), command, body)
|
return editor.Edit(cmd.Context(), command, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bodyEditor collects the body of an interactive form after the form ran. The editor is resolved
|
||||||
|
// first: without one, the body is typed into an inline multi-line field instead, so the input
|
||||||
|
// already given is never lost to a missing editor.
|
||||||
func (a *application) bodyEditor(cmd *cobra.Command) terminal.BodyEditor {
|
func (a *application) bodyEditor(cmd *cobra.Command) terminal.BodyEditor {
|
||||||
|
preferences, err := a.preferences()
|
||||||
|
if asker, ok := a.deps.Prompter.(terminal.Asker); ok && err == nil && preferences.Editor == "" {
|
||||||
|
return func(ctx context.Context, initial string) (terminal.Draft, error) {
|
||||||
|
body, err := asker.AskText(ctx, terminal.BodyTitle, initial)
|
||||||
|
return terminal.Draft{Body: body}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
return func(_ context.Context, initial string) (terminal.Draft, error) { return a.editDraft(cmd, initial) }
|
return func(_ context.Context, initial string) (terminal.Draft, error) { return a.editDraft(cmd, initial) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"gitea.dev/tea/internal/clierr"
|
"gitea.dev/tea/internal/clierr"
|
||||||
"gitea.dev/tea/internal/services"
|
"gitea.dev/tea/internal/services"
|
||||||
|
"gitea.dev/tea/internal/terminal"
|
||||||
"gitea.dev/tea/internal/transport"
|
"gitea.dev/tea/internal/transport"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -308,3 +309,30 @@ func TestReviewLifecycleEditorRejectsEmptyRequiredText(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBodyEditorTypesInlineWithoutAnEditor(t *testing.T) {
|
||||||
|
deps, _, _ := testDeps(t)
|
||||||
|
prompt := &askingPrompter{}
|
||||||
|
deps.Prompter = prompt
|
||||||
|
a, cmd := outputApplication(deps, "")
|
||||||
|
draft, err := a.bodyEditor(cmd)(context.Background(), "## Steps")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, terminal.Draft{Body: "typed body"}, draft)
|
||||||
|
require.Equal(t, []string{terminal.BodyTitle + " ## Steps"}, prompt.texts)
|
||||||
|
|
||||||
|
editor, path := newDraftEditor(t, "edited body")
|
||||||
|
deps.Editor = editor
|
||||||
|
deps.Getenv = envFunc(map[string]string{"EDITOR": "vi"})
|
||||||
|
a, cmd = outputApplication(deps, "")
|
||||||
|
draft, err = a.bodyEditor(cmd)(context.Background(), "## Steps")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, terminal.Draft{Path: path, Body: "edited body"}, draft)
|
||||||
|
require.Equal(t, "## Steps", editor.initial)
|
||||||
|
require.Len(t, prompt.texts, 1)
|
||||||
|
|
||||||
|
deps.Prompter = &scriptedPrompter{}
|
||||||
|
deps.Getenv = envFunc(nil)
|
||||||
|
a, cmd = outputApplication(deps, "")
|
||||||
|
_, err = a.bodyEditor(cmd)(context.Background(), "")
|
||||||
|
require.ErrorContains(t, err, "configure an editor")
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.dev/tea/internal/terminal"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// confirmAction asks c under the prompt policy unless --yes was supplied. Constructive actions set
|
||||||
|
// DefaultYes, destructive ones keep the default no, and irreversible ones set Typed. A declined
|
||||||
|
// confirmation returns terminal.ErrCanceled.
|
||||||
|
func (a *application) confirmAction(cmd *cobra.Command, c terminal.Confirmation) error {
|
||||||
|
s, err := a.deps.Settings.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return terminal.ConfirmAction(cmd.Context(), c, a.options.yes, a.deps.Streams, a.policy(s), a.deps.Prompter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireInput fills a missing value from a prompt titled label, such as "Label name", re-prompting
|
||||||
|
// until the answer is non-empty and validate, when set, accepts it. Without prompts it returns a
|
||||||
|
// usage error naming --flag. It reports whether it prompted.
|
||||||
|
func (a *application) requireInput(cmd *cobra.Command, value *string, flag, label string, validate func(string) error) (bool, error) {
|
||||||
|
s, err := a.deps.Settings.Load()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return terminal.RequireInput(cmd.Context(), value, flag, label, validate, a.deps.Streams, a.policy(s), a.deps.Prompter)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/tea/internal/clierr"
|
||||||
|
"gitea.dev/tea/internal/terminal"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// askingPrompter is scriptedPrompter with the terminal.Asker extension that FormPrompter has.
|
||||||
|
type askingPrompter struct {
|
||||||
|
scriptedPrompter
|
||||||
|
texts []string
|
||||||
|
defaults []bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *askingPrompter) Ask(ctx context.Context, title string, _ func(string) error) (string, error) {
|
||||||
|
return p.Input(ctx, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *askingPrompter) AskText(_ context.Context, title, initial string) (string, error) {
|
||||||
|
p.texts = append(p.texts, title+" "+initial)
|
||||||
|
return "typed body", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *askingPrompter) ConfirmDefault(ctx context.Context, title string, defaultYes bool) (bool, error) {
|
||||||
|
p.defaults = append(p.defaults, defaultYes)
|
||||||
|
return p.Confirm(ctx, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmActionFollowsThePromptPolicy(t *testing.T) {
|
||||||
|
c := terminal.Confirmation{Prompt: `Delete label "bug" from alice/x?`, NonTTY: `delete label "bug" from alice/x`}
|
||||||
|
deps, _, _ := testDeps(t)
|
||||||
|
a, cmd := outputApplication(deps, "")
|
||||||
|
err := a.confirmAction(cmd, c)
|
||||||
|
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
|
||||||
|
require.EqualError(t, err, `confirmation required to delete label "bug" from alice/x; rerun with --yes`)
|
||||||
|
a.options.yes = true
|
||||||
|
require.NoError(t, a.confirmAction(cmd, c))
|
||||||
|
|
||||||
|
prompt := &askingPrompter{}
|
||||||
|
deps.Prompter = prompt
|
||||||
|
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
|
||||||
|
a, cmd = outputApplication(deps, "")
|
||||||
|
err = a.confirmAction(cmd, c)
|
||||||
|
require.ErrorIs(t, err, terminal.ErrCanceled)
|
||||||
|
require.Equal(t, clierr.Canceled, clierr.ExitCode(err))
|
||||||
|
prompt.accept = true
|
||||||
|
require.NoError(t, a.confirmAction(cmd, terminal.Confirmation{Prompt: "Create pull request?", DefaultYes: true}))
|
||||||
|
require.Equal(t, []string{c.Prompt, "Create pull request?"}, prompt.confirmations)
|
||||||
|
require.Equal(t, []bool{false, true}, prompt.defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireInputPromptsWithTheLabel(t *testing.T) {
|
||||||
|
prompt := &scriptedPrompter{input: "bug"}
|
||||||
|
deps, _, _ := testDeps(t)
|
||||||
|
deps.Prompter = prompt
|
||||||
|
a, cmd := outputApplication(deps, "")
|
||||||
|
name := ""
|
||||||
|
_, err := a.requireInput(cmd, &name, "name", "Label name", nil)
|
||||||
|
require.EqualError(t, err, "missing required input: supply --name")
|
||||||
|
|
||||||
|
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
|
||||||
|
a, cmd = outputApplication(deps, "")
|
||||||
|
asked, err := a.requireInput(cmd, &name, "name", "Label name", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, asked)
|
||||||
|
require.Equal(t, "bug", name)
|
||||||
|
require.Equal(t, []string{"Label name"}, prompt.asked)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package terminal
|
||||||
|
|
||||||
|
import "charm.land/huh/v2"
|
||||||
|
|
||||||
|
// BodyTitle labels the markdown body of issues, pull requests and comments.
|
||||||
|
const BodyTitle = "Body (markdown):"
|
||||||
|
|
||||||
|
// maxVisibleOptions bounds how many options a multi-select shows before it scrolls.
|
||||||
|
const maxVisibleOptions = 8
|
||||||
|
|
||||||
|
// titleField is the required one-line title of an issue, pull request or similar resource.
|
||||||
|
func titleField(value *string) *huh.Input {
|
||||||
|
return huh.NewInput().Title("Title (required):").Value(value).Validate(huh.ValidateNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
// bodyField is an inline multi-line markdown field. It has no external editor: forms run the
|
||||||
|
// configured editor themselves, with private drafts, and never fall back to an unconfigured one.
|
||||||
|
func bodyField(title string, value *string) *huh.Text {
|
||||||
|
return huh.NewText().Title(title).Value(value).ExternalEditor(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// multiSelect lists every option, scrolling past maxVisibleOptions. Without an explicit height,
|
||||||
|
// huh sizes the list to its options minus the title line and hides the last option.
|
||||||
|
func multiSelect[T comparable](title string, options []huh.Option[T], value *[]T) *huh.MultiSelect[T] {
|
||||||
|
return huh.NewMultiSelect[T]().Title(title).Options(options...).Value(value).Height(min(len(options), maxVisibleOptions) + 1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package terminal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/huh/v2"
|
||||||
|
"gitea.dev/tea/internal/clierr"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// view renders form after the terminal reports width×height.
|
||||||
|
func view(form *huh.Form, width, height int) string {
|
||||||
|
form.Init()
|
||||||
|
form.Update(tea.WindowSizeMsg{Width: width, Height: height})
|
||||||
|
return form.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiSelectShowsEveryOption(t *testing.T) {
|
||||||
|
for _, count := range []int{1, 2, 5, maxVisibleOptions, 12} {
|
||||||
|
t.Run(fmt.Sprint(count), func(t *testing.T) {
|
||||||
|
var options []string
|
||||||
|
for i := range count {
|
||||||
|
options = append(options, fmt.Sprintf("user%02d", i))
|
||||||
|
}
|
||||||
|
var selected []string
|
||||||
|
rendered := view(huh.NewForm(huh.NewGroup(multiSelect("Assignees:", huh.NewOptions(options...), &selected))), 80, 24)
|
||||||
|
for i, option := range options {
|
||||||
|
if i < maxVisibleOptions {
|
||||||
|
require.Contains(t, rendered, option)
|
||||||
|
} else {
|
||||||
|
require.NotContains(t, rendered, option, "options past the first page scroll")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormsRefuseTerminalsWithoutSize(t *testing.T) {
|
||||||
|
for _, size := range [][2]int{{0, 0}, {80, 0}, {0, 24}} {
|
||||||
|
p, rendered := testPrompter(strings.NewReader("Title\r"))
|
||||||
|
p.size = func() (int, int, error) { return size[0], size[1], nil }
|
||||||
|
_, err := p.collectIssueProperties(context.Background(), "", "", IssueFormChoices{EditBody: noEdit})
|
||||||
|
require.ErrorIs(t, err, errNoTerminalSize, "%v", size)
|
||||||
|
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
|
||||||
|
require.Empty(t, rendered.String())
|
||||||
|
}
|
||||||
|
p, _ := testPrompter(strings.NewReader("Title\r"))
|
||||||
|
p.size = func() (int, int, error) { return 80, 24, nil }
|
||||||
|
values, err := p.collectIssueProperties(context.Background(), "", "", IssueFormChoices{EditBody: noEdit})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "Title", values.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPullFormNamesNoIssueFields(t *testing.T) {
|
||||||
|
p, rendered := accessiblePrompter(t, "n\nmain\nalice\nfeature\ny\nTitle\nBody\n")
|
||||||
|
_, err := p.CreatePull(context.Background(), PullFormChoices{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, rendered.String(), "Title (required):")
|
||||||
|
require.Contains(t, rendered.String(), BodyTitle)
|
||||||
|
require.NotContains(t, rendered.String(), "Issue")
|
||||||
|
}
|
||||||
@@ -98,18 +98,19 @@ type issueFields struct {
|
|||||||
labels []int64
|
labels []int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// groups builds title/body and, when canPush, assignee/milestone/label/due-date fields.
|
// groups builds title/body and, when canPush, assignee/milestone/label/due-date fields. The labels
|
||||||
|
// name no resource, as issues and pull requests share them.
|
||||||
func (f *issueFields) groups(editBody BodyEditor, canPush bool, assignees []string, milestones []IssueMilestoneChoice, labels []IssueLabelChoice) []*huh.Group {
|
func (f *issueFields) groups(editBody BodyEditor, canPush bool, assignees []string, milestones []IssueMilestoneChoice, labels []IssueLabelChoice) []*huh.Group {
|
||||||
fields := []huh.Field{huh.NewInput().Title("Issue title (required):").Value(&f.title).Validate(huh.ValidateNotEmpty())}
|
fields := []huh.Field{titleField(&f.title)}
|
||||||
if editBody == nil {
|
if editBody == nil {
|
||||||
fields = append(fields, huh.NewText().Title("Issue description(markdown):").Value(&f.body))
|
fields = append(fields, bodyField(BodyTitle, &f.body))
|
||||||
}
|
}
|
||||||
groups := []*huh.Group{huh.NewGroup(fields...)}
|
groups := []*huh.Group{huh.NewGroup(fields...)}
|
||||||
if !canPush {
|
if !canPush {
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
if len(assignees) > 0 {
|
if len(assignees) > 0 {
|
||||||
groups = append(groups, huh.NewGroup(huh.NewMultiSelect[string]().Title("Assignees:").Options(huh.NewOptions(assignees...)...).Value(&f.assignees)))
|
groups = append(groups, huh.NewGroup(multiSelect("Assignees:", huh.NewOptions(assignees...), &f.assignees)))
|
||||||
}
|
}
|
||||||
if len(milestones) > 0 {
|
if len(milestones) > 0 {
|
||||||
options := []huh.Option[int64]{huh.NewOption("[none]", int64(0))}
|
options := []huh.Option[int64]{huh.NewOption("[none]", int64(0))}
|
||||||
@@ -123,7 +124,7 @@ func (f *issueFields) groups(editBody BodyEditor, canPush bool, assignees []stri
|
|||||||
for _, label := range labels {
|
for _, label := range labels {
|
||||||
options = append(options, huh.NewOption(label.Name, label.ID))
|
options = append(options, huh.NewOption(label.Name, label.ID))
|
||||||
}
|
}
|
||||||
groups = append(groups, huh.NewGroup(huh.NewMultiSelect[int64]().Title("Labels:").Options(options...).Value(&f.labels)))
|
groups = append(groups, huh.NewGroup(multiSelect("Labels:", options, &f.labels)))
|
||||||
}
|
}
|
||||||
return append(groups, huh.NewGroup(dueDateInput("Due date:", &f.deadline)))
|
return append(groups, huh.NewGroup(dueDateInput("Due date:", &f.deadline)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,5 +32,5 @@ func TestIssueFormMarksTitleRequired(t *testing.T) {
|
|||||||
p, rendered := accessiblePrompter(t, "Title\n")
|
p, rendered := accessiblePrompter(t, "Title\n")
|
||||||
_, err := p.collectIssueProperties(context.Background(), "", "", IssueFormChoices{EditBody: noEdit})
|
_, err := p.collectIssueProperties(context.Background(), "", "", IssueFormChoices{EditBody: noEdit})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Contains(t, rendered.String(), "Issue title (required):")
|
require.Contains(t, rendered.String(), "Title (required):")
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,7 @@ func TestPullFormMarksRequiredFields(t *testing.T) {
|
|||||||
{
|
{
|
||||||
"without head", "n\nmain\nalice\nfeature\ny\nTitle\n",
|
"without head", "n\nmain\nalice\nfeature\ny\nTitle\n",
|
||||||
PullFormChoices{},
|
PullFormChoices{},
|
||||||
[]string{"Target branch (required):", "Source branch (required):", "Issue title (required):"},
|
[]string{"Target branch (required):", "Source branch (required):", "Title (required):"},
|
||||||
nil,
|
nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ func (p *FormPrompter) CreateReview(ctx context.Context, choices ReviewFormChoic
|
|||||||
return values, err
|
return values, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
field := huh.NewText().Title("Concluding comment(markdown):").Value(&body)
|
field := bodyField("Concluding comment (markdown):", &body)
|
||||||
if required {
|
if required {
|
||||||
field = field.Title("Concluding comment(markdown) (required):").Validate(huh.ValidateNotEmpty())
|
field = field.Title("Concluding comment (markdown, required):").Validate(huh.ValidateNotEmpty())
|
||||||
}
|
}
|
||||||
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(field))); err != nil {
|
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(field))); err != nil {
|
||||||
return ReviewFormValues{}, err
|
return ReviewFormValues{}, err
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ func TestReviewFormRequiresCommentOnlyWhenRequestingChanges(t *testing.T) {
|
|||||||
p, rendered := accessiblePrompter(t, "3\n")
|
p, rendered := accessiblePrompter(t, "3\n")
|
||||||
_, err := p.CreateReview(context.Background(), ReviewFormChoices{})
|
_, err := p.CreateReview(context.Background(), ReviewFormChoices{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Contains(t, rendered.String(), "Concluding comment(markdown):")
|
require.Contains(t, rendered.String(), "Concluding comment (markdown):")
|
||||||
require.NotContains(t, rendered.String(), "(required)")
|
require.NotContains(t, rendered.String(), "required")
|
||||||
|
|
||||||
p, rendered = accessiblePrompter(t, "2\n")
|
p, rendered = accessiblePrompter(t, "2\n")
|
||||||
_, err = p.CreateReview(context.Background(), ReviewFormChoices{})
|
_, err = p.CreateReview(context.Background(), ReviewFormChoices{})
|
||||||
require.ErrorContains(t, err, "concluding comment cannot be empty")
|
require.ErrorContains(t, err, "concluding comment cannot be empty")
|
||||||
require.Contains(t, rendered.String(), "Concluding comment(markdown) (required):")
|
require.Contains(t, rendered.String(), "Concluding comment (markdown, required):")
|
||||||
}
|
}
|
||||||
+145
-19
@@ -66,6 +66,10 @@ func (p Policy) Validate(s Streams) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrCanceled reports a declined confirmation or an aborted prompt. It exits with code 3 and is
|
||||||
|
// meant to be reported as "Canceled." rather than as an error.
|
||||||
|
var ErrCanceled = clierr.Wrap(clierr.Canceled, errors.New("canceled"))
|
||||||
|
|
||||||
// Prompter abstracts cancellable input collection and confirmation.
|
// Prompter abstracts cancellable input collection and confirmation.
|
||||||
type Prompter interface {
|
type Prompter interface {
|
||||||
Input(context.Context, string) (string, error)
|
Input(context.Context, string) (string, error)
|
||||||
@@ -73,30 +77,76 @@ type Prompter interface {
|
|||||||
Confirm(context.Context, string) (bool, error)
|
Confirm(context.Context, string) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Asker is the richer prompt API behind ConfirmAction and RequireInput: inline validation,
|
||||||
|
// multi-line text and confirmations with a default answer. FormPrompter implements it; a
|
||||||
|
// Prompter without it gets plain Input and Confirm behavior.
|
||||||
|
type Asker interface {
|
||||||
|
// Ask collects one line, re-prompting inline until validate accepts it.
|
||||||
|
Ask(ctx context.Context, title string, validate func(string) error) (string, error)
|
||||||
|
// AskText collects multi-line markdown, starting from initial.
|
||||||
|
AskText(ctx context.Context, title, initial string) (string, error)
|
||||||
|
// ConfirmDefault asks a yes/no question that Enter answers with defaultYes.
|
||||||
|
ConfirmDefault(ctx context.Context, title string, defaultYes bool) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
// FormPrompter uses cancellable terminal forms with explicitly injected streams.
|
// FormPrompter uses cancellable terminal forms with explicitly injected streams.
|
||||||
type FormPrompter struct{ Streams Streams }
|
type FormPrompter struct {
|
||||||
|
Streams Streams
|
||||||
|
// size reports the size of the terminal that forms render on; nil queries ErrOut.
|
||||||
|
size func() (width, height int, err error)
|
||||||
|
}
|
||||||
|
|
||||||
func promptError(ctx context.Context, err error) error {
|
func promptError(ctx context.Context, err error) error {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
if errors.Is(err, huh.ErrUserAborted) {
|
if errors.Is(err, huh.ErrUserAborted) {
|
||||||
return clierr.Wrap(clierr.Canceled, errors.New("input canceled"))
|
return ErrCanceled
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errNoTerminalSize refuses forms on a terminal without a size, such as a pty that `script` opens
|
||||||
|
// without a terminal. Forms size their fields from it, and a zero size panics in bubbles; nobody
|
||||||
|
// could see the form there anyway.
|
||||||
|
var errNoTerminalSize = clierr.Wrap(clierr.Usage, errors.New("terminal size unknown; pass the values as flags or use --no-input"))
|
||||||
|
|
||||||
func (p *FormPrompter) runForm(ctx context.Context, form *huh.Form) error {
|
func (p *FormPrompter) runForm(ctx context.Context, form *huh.Form) error {
|
||||||
|
size := p.size
|
||||||
|
if file, ok := p.Streams.ErrOut.(*os.File); ok && size == nil {
|
||||||
|
size = func() (int, int, error) { return term.GetSize(int(file.Fd())) }
|
||||||
|
}
|
||||||
|
if size != nil {
|
||||||
|
if width, height, err := size(); err == nil && (width <= 0 || height <= 0) {
|
||||||
|
return errNoTerminalSize
|
||||||
|
}
|
||||||
|
}
|
||||||
return promptError(ctx, form.WithInput(p.Streams.In).WithOutput(p.Streams.ErrOut).RunWithContext(ctx))
|
return promptError(ctx, form.WithInput(p.Streams.In).WithOutput(p.Streams.ErrOut).RunWithContext(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input collects one field using the configured terminal streams.
|
// Input collects one field using the configured terminal streams.
|
||||||
func (p *FormPrompter) Input(ctx context.Context, title string) (string, error) {
|
func (p *FormPrompter) Input(ctx context.Context, title string) (string, error) {
|
||||||
|
return p.Ask(ctx, title, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask collects one line, re-prompting inline until validate accepts the trimmed answer.
|
||||||
|
func (p *FormPrompter) Ask(ctx context.Context, title string, validate func(string) error) (string, error) {
|
||||||
var value string
|
var value string
|
||||||
err := p.runForm(ctx, huh.NewForm(huh.NewGroup(huh.NewInput().Title(title).Value(&value))))
|
input := huh.NewInput().Title(title).Value(&value)
|
||||||
|
if validate != nil {
|
||||||
|
input = input.Validate(func(s string) error { return validate(strings.TrimSpace(s)) })
|
||||||
|
}
|
||||||
|
err := p.runForm(ctx, huh.NewForm(huh.NewGroup(input)))
|
||||||
return strings.TrimSpace(value), err
|
return strings.TrimSpace(value), err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AskText collects multi-line markdown in an inline field, starting from initial.
|
||||||
|
func (p *FormPrompter) AskText(ctx context.Context, title, initial string) (string, error) {
|
||||||
|
value := initial
|
||||||
|
err := p.runForm(ctx, huh.NewForm(huh.NewGroup(bodyField(title, &value))))
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
|
||||||
// SecretInput collects a value without displaying it on the terminal.
|
// SecretInput collects a value without displaying it on the terminal.
|
||||||
func (p *FormPrompter) SecretInput(ctx context.Context, title string) (string, error) {
|
func (p *FormPrompter) SecretInput(ctx context.Context, title string) (string, error) {
|
||||||
var value string
|
var value string
|
||||||
@@ -104,46 +154,122 @@ func (p *FormPrompter) SecretInput(ctx context.Context, title string) (string, e
|
|||||||
return value, err
|
return value, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm collects a yes/no choice through cancellable terminal input.
|
// Confirm asks a yes/no question that Enter answers with no.
|
||||||
func (p *FormPrompter) Confirm(ctx context.Context, title string) (bool, error) {
|
func (p *FormPrompter) Confirm(ctx context.Context, title string) (bool, error) {
|
||||||
var accepted bool
|
return p.ConfirmDefault(ctx, title, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmDefault asks a yes/no question that Enter answers with defaultYes.
|
||||||
|
func (p *FormPrompter) ConfirmDefault(ctx context.Context, title string, defaultYes bool) (bool, error) {
|
||||||
|
accepted := defaultYes
|
||||||
err := p.runForm(ctx, huh.NewForm(huh.NewGroup(huh.NewConfirm().Title(title).Value(&accepted))))
|
err := p.runForm(ctx, huh.NewForm(huh.NewGroup(huh.NewConfirm().Title(title).Value(&accepted))))
|
||||||
return accepted, err
|
return accepted, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Require fills missing input or returns an actionable usage error.
|
// RequireInput fills a missing value from a prompt titled label, re-prompting inline until the
|
||||||
func Require(ctx context.Context, value *string, flag string, streams Streams, policy Policy, prompt Prompter) (bool, error) {
|
// answer is non-empty and validate, when set, accepts it. When prompts are unavailable it returns
|
||||||
|
// a usage error naming --flag. It reports whether it prompted.
|
||||||
|
func RequireInput(ctx context.Context, value *string, flag, label string, validate func(string) error, streams Streams, policy Policy, prompt Prompter) (bool, error) {
|
||||||
if *value != "" {
|
if *value != "" {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
if !policy.CanPrompt(streams) {
|
if !policy.CanPrompt(streams) {
|
||||||
return false, clierr.Wrap(clierr.Usage, fmt.Errorf("missing required input: supply --%s", flag))
|
return false, clierr.Wrap(clierr.Usage, fmt.Errorf("missing required input: supply --%s", flag))
|
||||||
}
|
}
|
||||||
entered, err := prompt.Input(ctx, flag)
|
var entered string
|
||||||
|
var err error
|
||||||
|
if asker, ok := prompt.(Asker); ok {
|
||||||
|
entered, err = asker.Ask(ctx, label, func(s string) error {
|
||||||
|
if s == "" {
|
||||||
|
return errors.New("a value is required")
|
||||||
|
}
|
||||||
|
if validate != nil {
|
||||||
|
return validate(s)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
} else if entered, err = prompt.Input(ctx, label); err == nil {
|
||||||
|
switch {
|
||||||
|
case entered == "":
|
||||||
|
err = clierr.Wrap(clierr.Usage, fmt.Errorf("--%s cannot be empty", flag))
|
||||||
|
case validate != nil:
|
||||||
|
if invalid := validate(entered); invalid != nil {
|
||||||
|
err = clierr.Wrap(clierr.Usage, fmt.Errorf("--%s: %w", flag, invalid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if entered == "" {
|
|
||||||
return false, clierr.Wrap(clierr.Usage, fmt.Errorf("--%s cannot be empty", flag))
|
|
||||||
}
|
|
||||||
*value = entered
|
*value = entered
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm requires explicit acceptance unless confirmation was supplied by flag.
|
// Require is RequireInput titled by the flag name, for callers without a human label.
|
||||||
func Confirm(ctx context.Context, description string, yes bool, streams Streams, policy Policy, prompt Prompter) error {
|
func Require(ctx context.Context, value *string, flag string, streams Streams, policy Policy, prompt Prompter) (bool, error) {
|
||||||
|
return RequireInput(ctx, value, flag, flag, nil, streams, policy, prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmation describes the question asked before an action.
|
||||||
|
type Confirmation struct {
|
||||||
|
// Prompt asks about the action in sentence case: `Delete label "bug" from alice/x?`.
|
||||||
|
Prompt string
|
||||||
|
// DefaultYes lets Enter accept, for constructive actions the user just asked for, such as
|
||||||
|
// submitting a form they filled in. Destructive actions keep the default no.
|
||||||
|
DefaultYes bool
|
||||||
|
// Typed, when set, must be typed to confirm, for irreversible actions such as deleting a repository.
|
||||||
|
Typed string
|
||||||
|
// NonTTY names the action in the error returned when no prompt is possible and --yes is
|
||||||
|
// missing: `delete label "bug" from alice/x`.
|
||||||
|
NonTTY string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmAction asks c unless yes is set. A declined or aborted confirmation returns ErrCanceled.
|
||||||
|
func ConfirmAction(ctx context.Context, c Confirmation, yes bool, streams Streams, policy Policy, prompt Prompter) error {
|
||||||
if yes {
|
if yes {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !policy.CanPrompt(streams) {
|
if !policy.CanPrompt(streams) {
|
||||||
return clierr.Wrap(clierr.Usage, errors.New("confirmation required: supply --yes"))
|
action := ""
|
||||||
|
if c.NonTTY != "" {
|
||||||
|
action = " to " + c.NonTTY
|
||||||
|
}
|
||||||
|
return clierr.Wrap(clierr.Usage, fmt.Errorf("confirmation required%s; rerun with --yes", action))
|
||||||
}
|
}
|
||||||
accepted, err := prompt.Confirm(ctx, description)
|
asker, rich := prompt.(Asker)
|
||||||
if err != nil {
|
if c.Typed != "" {
|
||||||
|
title := c.Prompt + " Type " + c.Typed + " to confirm:"
|
||||||
|
matches := func(s string) bool { return strings.EqualFold(strings.TrimSpace(s), c.Typed) }
|
||||||
|
if rich {
|
||||||
|
_, err := asker.Ask(ctx, title, func(s string) error {
|
||||||
|
if !matches(s) {
|
||||||
|
return fmt.Errorf("type %s to confirm", c.Typed)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
typed, err := prompt.Input(ctx, title)
|
||||||
|
if err == nil && !matches(typed) {
|
||||||
|
err = ErrCanceled
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !accepted {
|
var accepted bool
|
||||||
return clierr.Wrap(clierr.Canceled, errors.New("operation canceled"))
|
var err error
|
||||||
|
if rich {
|
||||||
|
accepted, err = asker.ConfirmDefault(ctx, c.Prompt, c.DefaultYes)
|
||||||
|
} else {
|
||||||
|
accepted, err = prompt.Confirm(ctx, c.Prompt)
|
||||||
}
|
}
|
||||||
return nil
|
if err == nil && !accepted {
|
||||||
|
err = ErrCanceled
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm requires explicit acceptance unless confirmation was supplied by flag.
|
||||||
|
// It is ConfirmAction for callers that predate Confirmation.
|
||||||
|
func Confirm(ctx context.Context, description string, yes bool, streams Streams, policy Policy, prompt Prompter) error {
|
||||||
|
return ConfirmAction(ctx, Confirmation{Prompt: description}, yes, streams, policy, prompt)
|
||||||
}
|
}
|
||||||
@@ -6,10 +6,13 @@ package terminal
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"charm.land/huh/v2"
|
||||||
|
"gitea.dev/tea/internal/clierr"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -61,3 +64,139 @@ func TestPromptPolicy(t *testing.T) {
|
|||||||
_, err = Require(context.Background(), &value, "title", Streams{}, Policy{}, prompt)
|
_, err = Require(context.Background(), &value, "title", Streams{}, Policy{}, prompt)
|
||||||
require.ErrorContains(t, err, "--title")
|
require.ErrorContains(t, err, "--title")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// askingPrompt answers Asker prompts from scripted answers. Ask skips answers that validate rejects,
|
||||||
|
// as an inline re-prompt does, and cancels when none is left.
|
||||||
|
type askingPrompt struct {
|
||||||
|
fakePrompt
|
||||||
|
answers, rejected, titles []string
|
||||||
|
accept bool
|
||||||
|
defaults []bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *askingPrompt) Ask(_ context.Context, title string, validate func(string) error) (string, error) {
|
||||||
|
p.titles = append(p.titles, title)
|
||||||
|
for len(p.answers) > 0 {
|
||||||
|
answer := p.answers[0]
|
||||||
|
p.answers = p.answers[1:]
|
||||||
|
if validate == nil || validate(answer) == nil {
|
||||||
|
return answer, nil
|
||||||
|
}
|
||||||
|
p.rejected = append(p.rejected, answer)
|
||||||
|
}
|
||||||
|
return "", ErrCanceled
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *askingPrompt) AskText(_ context.Context, title, initial string) (string, error) {
|
||||||
|
p.titles = append(p.titles, title)
|
||||||
|
return initial + " edited", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *askingPrompt) ConfirmDefault(_ context.Context, title string, defaultYes bool) (bool, error) {
|
||||||
|
p.titles = append(p.titles, title)
|
||||||
|
p.defaults = append(p.defaults, defaultYes)
|
||||||
|
return p.accept, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmAction(t *testing.T) {
|
||||||
|
terminal := Streams{InTTY: true, ErrTTY: true}
|
||||||
|
deleteLabel := Confirmation{Prompt: `Delete label "bug" from alice/x?`, NonTTY: `delete label "bug" from alice/x`}
|
||||||
|
typed := Confirmation{Prompt: "Delete alice/x?", Typed: "alice/x"}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
c Confirmation
|
||||||
|
yes bool
|
||||||
|
streams Streams
|
||||||
|
prompt Prompter
|
||||||
|
code int
|
||||||
|
message string
|
||||||
|
rejected []string
|
||||||
|
}{
|
||||||
|
{name: "accepted by flag", c: deleteLabel, yes: true, prompt: &fakePrompt{}},
|
||||||
|
{name: "names the action without a terminal", c: deleteLabel, prompt: &fakePrompt{}, code: clierr.Usage, message: `confirmation required to delete label "bug" from alice/x; rerun with --yes`},
|
||||||
|
{name: "without a terminal or action", c: Confirmation{Prompt: "Go?"}, prompt: &fakePrompt{}, code: clierr.Usage, message: "confirmation required; rerun with --yes"},
|
||||||
|
{name: "accepted", c: deleteLabel, streams: terminal, prompt: &askingPrompt{accept: true}},
|
||||||
|
{name: "declined", c: deleteLabel, streams: terminal, prompt: &askingPrompt{}, code: clierr.Canceled, message: "canceled"},
|
||||||
|
{name: "declined to a plain prompter", c: deleteLabel, streams: terminal, prompt: &fakePrompt{}, code: clierr.Canceled, message: "canceled"},
|
||||||
|
{name: "re-prompts until the name is typed", c: typed, streams: terminal, prompt: &askingPrompt{answers: []string{"alice/y", " Alice/X "}}, rejected: []string{"alice/y"}},
|
||||||
|
{name: "typed wrong to a plain prompter", c: typed, streams: terminal, prompt: &fakePrompt{}, code: clierr.Canceled, message: "canceled"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := ConfirmAction(context.Background(), tc.c, tc.yes, tc.streams, Policy{Enabled: true}, tc.prompt)
|
||||||
|
require.Equal(t, tc.code, clierr.ExitCode(err), "%v", err)
|
||||||
|
if tc.message != "" {
|
||||||
|
require.EqualError(t, err, tc.message)
|
||||||
|
}
|
||||||
|
if tc.code == clierr.Canceled {
|
||||||
|
require.ErrorIs(t, err, ErrCanceled)
|
||||||
|
}
|
||||||
|
if asker, ok := tc.prompt.(*askingPrompt); ok {
|
||||||
|
require.Equal(t, tc.rejected, asker.rejected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmActionPassesTheDefault(t *testing.T) {
|
||||||
|
prompt := &askingPrompt{accept: true}
|
||||||
|
streams := Streams{InTTY: true, ErrTTY: true}
|
||||||
|
require.NoError(t, ConfirmAction(context.Background(), Confirmation{Prompt: "Create pull request?", DefaultYes: true}, false, streams, Policy{Enabled: true}, prompt))
|
||||||
|
require.NoError(t, Confirm(context.Background(), "Delete branch?", false, streams, Policy{Enabled: true}, prompt))
|
||||||
|
require.Equal(t, []bool{true, false}, prompt.defaults)
|
||||||
|
require.Equal(t, []string{"Create pull request?", "Delete branch?"}, prompt.titles)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireInput(t *testing.T) {
|
||||||
|
streams, policy := Streams{InTTY: true, ErrTTY: true}, Policy{Enabled: true}
|
||||||
|
hex := func(s string) error {
|
||||||
|
if len(s) != 6 {
|
||||||
|
return errors.New("use six hex digits")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
asker := &askingPrompt{answers: []string{"", "red", "d73a4a"}}
|
||||||
|
value := ""
|
||||||
|
asked, err := RequireInput(context.Background(), &value, "color", "Color (hex)", hex, streams, policy, asker)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, asked)
|
||||||
|
require.Equal(t, "d73a4a", value)
|
||||||
|
require.Equal(t, []string{"", "red"}, asker.rejected)
|
||||||
|
require.Equal(t, []string{"Color (hex)"}, asker.titles)
|
||||||
|
|
||||||
|
value = ""
|
||||||
|
_, err = RequireInput(context.Background(), &value, "color", "Color (hex)", hex, streams, policy, &fakePrompt{})
|
||||||
|
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
|
||||||
|
require.EqualError(t, err, "--color: use six hex digits")
|
||||||
|
|
||||||
|
_, err = RequireInput(context.Background(), &value, "color", "Color (hex)", hex, Streams{}, policy, asker)
|
||||||
|
require.EqualError(t, err, "missing required input: supply --color")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormPrompterConfirmDefault(t *testing.T) {
|
||||||
|
for _, defaultYes := range []bool{true, false} {
|
||||||
|
p, _ := accessiblePrompter(t, "\n")
|
||||||
|
accepted, err := p.ConfirmDefault(context.Background(), "Submit?", defaultYes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, defaultYes, accepted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormPrompterAskRepromptsInline(t *testing.T) {
|
||||||
|
p, rendered := accessiblePrompter(t, "\n bug \n")
|
||||||
|
value, err := p.Ask(context.Background(), "Label name", func(s string) error {
|
||||||
|
if s == "" {
|
||||||
|
return errors.New("a value is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "bug", value)
|
||||||
|
require.Contains(t, rendered.String(), "a value is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptAbortIsCanceled(t *testing.T) {
|
||||||
|
err := promptError(context.Background(), huh.ErrUserAborted)
|
||||||
|
require.ErrorIs(t, err, ErrCanceled)
|
||||||
|
require.Equal(t, clierr.Canceled, clierr.ExitCode(err))
|
||||||
|
}
|
||||||
Reference in new issue
Block a user