mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
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>
203 lines
7.7 KiB
Go
203 lines
7.7 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package terminal
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
|
|
"charm.land/huh/v2"
|
|
"gitea.dev/tea/internal/clierr"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// testPrompter returns a form prompter reading in and the buffer its forms render to.
|
|
func testPrompter(in io.Reader) (*FormPrompter, *bytes.Buffer) {
|
|
rendered := &bytes.Buffer{}
|
|
return &FormPrompter{Streams: Streams{In: in, Out: &bytes.Buffer{}, ErrOut: rendered, InTTY: true, ErrTTY: true}}, rendered
|
|
}
|
|
|
|
// accessiblePrompter is testPrompter in accessible mode, which renders field titles as plain text.
|
|
func accessiblePrompter(t *testing.T, input string) (*FormPrompter, *bytes.Buffer) {
|
|
t.Helper()
|
|
t.Setenv("TERM", "dumb")
|
|
return testPrompter(strings.NewReader(input))
|
|
}
|
|
|
|
func noEdit(context.Context, string) (Draft, error) { return Draft{}, nil }
|
|
|
|
type fakePrompt struct{ inputs int }
|
|
|
|
func (f *fakePrompt) Input(context.Context, string) (string, error) {
|
|
f.inputs++
|
|
return "provided", nil
|
|
}
|
|
func (f *fakePrompt) SecretInput(context.Context, string) (string, error) { return "", nil }
|
|
func (f *fakePrompt) Confirm(context.Context, string) (bool, error) { return false, nil }
|
|
|
|
func TestPromptPolicy(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
policy Policy
|
|
streams Streams
|
|
expected bool
|
|
}{
|
|
{Policy{Enabled: true}, Streams{InTTY: true, ErrTTY: true}, true},
|
|
{Policy{Enabled: true}, Streams{InTTY: true, OutTTY: true}, false},
|
|
{Policy{Enabled: true, NoInput: true}, Streams{InTTY: true, ErrTTY: true}, false},
|
|
{Policy{Enabled: true, Machine: true}, Streams{InTTY: true, ErrTTY: true}, false},
|
|
{Policy{Enabled: true, Machine: true, Interactive: true}, Streams{InTTY: true, ErrTTY: true}, true},
|
|
} {
|
|
require.Equal(t, tc.expected, tc.policy.CanPrompt(tc.streams))
|
|
}
|
|
prompt := &fakePrompt{}
|
|
value := "supplied"
|
|
asked, err := Require(context.Background(), &value, "title", Streams{}, Policy{}, prompt)
|
|
require.NoError(t, err)
|
|
require.False(t, asked)
|
|
require.Zero(t, prompt.inputs)
|
|
value = ""
|
|
_, err = Require(context.Background(), &value, "title", Streams{}, Policy{}, prompt)
|
|
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))
|
|
}
|