1
0
mirror of https://github.com/docker/cli.git synced 2025-04-18 19:24:03 +03:00

Merge pull request #5952 from thaJeztah/move_prompt_utils_step1

cli/command: move prompt utilities to separate package
This commit is contained in:
Sebastiaan van Stijn 2025-04-11 16:11:12 +02:00 committed by GitHub
commit 6aa93d1f40
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 366 additions and 269 deletions

View File

@ -9,6 +9,7 @@ import (
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/internal/prompt"
"github.com/docker/cli/opts"
"github.com/docker/docker/api/types"
"github.com/docker/docker/errdefs"
@ -69,7 +70,7 @@ func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions)
warning = allCacheWarning
}
if !options.force {
r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), warning)
r, err := prompt.Confirm(ctx, dockerCli.In(), dockerCli.Out(), warning)
if err != nil {
return 0, "", err
}

View File

@ -7,6 +7,7 @@ import (
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/internal/prompt"
"github.com/docker/cli/opts"
"github.com/docker/docker/errdefs"
units "github.com/docker/go-units"
@ -56,7 +57,7 @@ func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions)
pruneFilters := command.PruneFilters(dockerCli, options.filter.Value())
if !options.force {
r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), warning)
r, err := prompt.Confirm(ctx, dockerCli.In(), dockerCli.Out(), warning)
if err != nil {
return 0, "", err
}

View File

@ -9,6 +9,7 @@ import (
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/internal/prompt"
"github.com/docker/cli/opts"
"github.com/docker/docker/errdefs"
units "github.com/docker/go-units"
@ -70,7 +71,7 @@ func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions)
warning = allImageWarning
}
if !options.force {
r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), warning)
r, err := prompt.Confirm(ctx, dockerCli.In(), dockerCli.Out(), warning)
if err != nil {
return 0, "", err
}

View File

@ -6,6 +6,7 @@ import (
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/internal/prompt"
"github.com/docker/cli/opts"
"github.com/docker/docker/errdefs"
"github.com/pkg/errors"
@ -52,7 +53,7 @@ func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions)
pruneFilters := command.PruneFilters(dockerCli, options.filter.Value())
if !options.force {
r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), warning)
r, err := prompt.Confirm(ctx, dockerCli.In(), dockerCli.Out(), warning)
if err != nil {
return "", err
}

View File

@ -8,6 +8,7 @@ import (
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/internal/prompt"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/errdefs"
"github.com/spf13/cobra"
@ -49,7 +50,7 @@ func runRemove(ctx context.Context, dockerCLI command.Cli, networks []string, op
for _, name := range networks {
nw, _, err := apiClient.NetworkInspectWithRaw(ctx, name, network.InspectOptions{})
if err == nil && nw.Ingress {
r, err := command.PromptForConfirmation(ctx, dockerCLI.In(), dockerCLI.Out(), ingressWarning)
r, err := prompt.Confirm(ctx, dockerCLI.In(), dockerCLI.Out(), ingressWarning)
if err != nil {
return err
}

View File

@ -10,6 +10,7 @@ import (
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/image"
"github.com/docker/cli/cli/internal/jsonstream"
"github.com/docker/cli/internal/prompt"
"github.com/docker/docker/api/types"
registrytypes "github.com/docker/docker/api/types/registry"
"github.com/docker/docker/registry"
@ -133,12 +134,12 @@ func runInstall(ctx context.Context, dockerCLI command.Cli, opts pluginOptions)
return nil
}
func acceptPrivileges(dockerCLI command.Cli, name string) func(ctx context.Context, privileges types.PluginPrivileges) (bool, error) {
func acceptPrivileges(dockerCLI command.Streams, name string) func(ctx context.Context, privileges types.PluginPrivileges) (bool, error) {
return func(ctx context.Context, privileges types.PluginPrivileges) (bool, error) {
_, _ = fmt.Fprintf(dockerCLI.Out(), "Plugin %q is requesting the following privileges:\n", name)
for _, privilege := range privileges {
_, _ = fmt.Fprintf(dockerCLI.Out(), " - %s: %v\n", privilege.Name, privilege.Value)
}
return command.PromptForConfirmation(ctx, dockerCLI.In(), dockerCLI.Out(), "Do you grant the above permissions?")
return prompt.Confirm(ctx, dockerCLI.In(), dockerCLI.Out(), "Do you grant the above permissions?")
}
}

View File

@ -9,6 +9,7 @@ import (
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/internal/jsonstream"
"github.com/docker/cli/internal/prompt"
"github.com/docker/docker/errdefs"
"github.com/pkg/errors"
"github.com/spf13/cobra"
@ -64,7 +65,7 @@ func runUpgrade(ctx context.Context, dockerCLI command.Cli, opts pluginOptions)
_, _ = fmt.Fprintf(dockerCLI.Out(), "Upgrading plugin %s from %s to %s\n", p.Name, reference.FamiliarString(old), reference.FamiliarString(remote))
if !opts.skipRemoteCheck && remote.String() != old.String() {
r, err := command.PromptForConfirmation(ctx, dockerCLI.In(), dockerCLI.Out(), "Plugin images do not match, are you sure?")
r, err := prompt.Confirm(ctx, dockerCLI.In(), dockerCLI.Out(), "Plugin images do not match, are you sure?")
if err != nil {
return err
}

View File

@ -13,6 +13,7 @@ import (
configtypes "github.com/docker/cli/cli/config/types"
"github.com/docker/cli/cli/hints"
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/internal/prompt"
"github.com/docker/cli/internal/tui"
registrytypes "github.com/docker/docker/api/types/registry"
"github.com/morikuni/aec"
@ -148,16 +149,16 @@ func PromptUserForCredentials(ctx context.Context, cli Cli, argUser, argPassword
}
}
var prompt string
var msg string
defaultUsername = strings.TrimSpace(defaultUsername)
if defaultUsername == "" {
prompt = "Username: "
msg = "Username: "
} else {
prompt = fmt.Sprintf("Username (%s): ", defaultUsername)
msg = fmt.Sprintf("Username (%s): ", defaultUsername)
}
var err error
argUser, err = PromptForInput(ctx, cli.In(), cli.Out(), prompt)
argUser, err = prompt.ReadInput(ctx, cli.In(), cli.Out(), msg)
if err != nil {
return registrytypes.AuthConfig{}, err
}
@ -171,7 +172,7 @@ func PromptUserForCredentials(ctx context.Context, cli Cli, argUser, argPassword
argPassword = strings.TrimSpace(argPassword)
if argPassword == "" {
restoreInput, err := DisableInputEcho(cli.In())
restoreInput, err := prompt.DisableInputEcho(cli.In())
if err != nil {
return registrytypes.AuthConfig{}, err
}
@ -188,7 +189,7 @@ func PromptUserForCredentials(ctx context.Context, cli Cli, argUser, argPassword
out := tui.NewOutput(cli.Err())
out.PrintNote("A Personal Access Token (PAT) can be used instead.\n" +
"To create a PAT, visit " + aec.Underline.Apply("https://app.docker.com/settings") + "\n\n")
argPassword, err = PromptForInput(ctx, cli.In(), cli.Out(), "Password: ")
argPassword, err = prompt.ReadInput(ctx, cli.In(), cli.Out(), "Password: ")
if err != nil {
return registrytypes.AuthConfig{}, err
}

View File

@ -9,9 +9,9 @@ import (
"time"
"github.com/creack/pty"
"github.com/docker/cli/cli/command"
configtypes "github.com/docker/cli/cli/config/types"
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/internal/prompt"
"github.com/docker/cli/internal/test"
registrytypes "github.com/docker/docker/api/types/registry"
"github.com/docker/docker/api/types/system"
@ -492,7 +492,7 @@ func TestLoginTermination(t *testing.T) {
case <-time.After(1 * time.Second):
t.Fatal("timed out after 1 second. `runLogin` did not return")
case err := <-runErr:
assert.ErrorIs(t, err, command.ErrPromptTerminated)
assert.ErrorIs(t, err, prompt.ErrTerminated)
}
}

View File

@ -15,6 +15,7 @@ import (
"github.com/docker/cli/cli/command/image"
"github.com/docker/cli/cli/command/network"
"github.com/docker/cli/cli/command/volume"
"github.com/docker/cli/internal/prompt"
"github.com/docker/cli/opts"
"github.com/docker/docker/api/types/versions"
"github.com/docker/docker/errdefs"
@ -77,7 +78,7 @@ func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions)
return errors.New(`ERROR: The "until" filter is not supported with "--volumes"`)
}
if !options.force {
r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), confirmationMessage(dockerCli, options))
r, err := prompt.Confirm(ctx, dockerCli.In(), dockerCli.Out(), confirmationMessage(dockerCli, options))
if err != nil {
return err
}

View File

@ -8,6 +8,7 @@ import (
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/image"
"github.com/docker/cli/cli/trust"
"github.com/docker/cli/internal/prompt"
"github.com/docker/docker/errdefs"
"github.com/pkg/errors"
"github.com/spf13/cobra"
@ -44,7 +45,7 @@ func revokeTrust(ctx context.Context, dockerCLI command.Cli, remote string, opti
return errors.New("cannot use a digest reference for IMAGE:TAG")
}
if imgRefAndAuth.Tag() == "" && !options.forceYes {
deleteRemote, err := command.PromptForConfirmation(ctx, dockerCLI.In(), dockerCLI.Out(), fmt.Sprintf("Confirm you would like to delete all signature data for %s?", remote))
deleteRemote, err := prompt.Confirm(ctx, dockerCLI.In(), dockerCLI.Out(), fmt.Sprintf("Confirm you would like to delete all signature data for %s?", remote))
if err != nil {
return err
}

View File

@ -9,6 +9,7 @@ import (
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/image"
"github.com/docker/cli/cli/trust"
"github.com/docker/cli/internal/prompt"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/theupdateframework/notary/client"
@ -82,11 +83,7 @@ func maybePromptForSignerRemoval(ctx context.Context, dockerCLI command.Cli, rep
"Are you sure you want to continue?",
signerName, repoName, repoName,
)
removeSigner, err := command.PromptForConfirmation(ctx, dockerCLI.In(), dockerCLI.Out(), message)
if err != nil {
return false, err
}
return removeSigner, nil
return prompt.Confirm(ctx, dockerCLI.In(), dockerCLI.Out(), message)
}
return false, nil
}

View File

@ -4,21 +4,17 @@
package command
import (
"bufio"
"context"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/docker/cli/cli/config"
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/internal/prompt"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/errdefs"
"github.com/moby/sys/atomicwriter"
"github.com/moby/term"
"github.com/pkg/errors"
"github.com/spf13/pflag"
)
@ -36,21 +32,14 @@ func CopyToFile(outfile string, r io.Reader) error {
return err
}
var ErrPromptTerminated = errdefs.Cancelled(errors.New("prompt terminated"))
const ErrPromptTerminated = prompt.ErrTerminated
// DisableInputEcho disables input echo on the provided streams.In.
// This is useful when the user provides sensitive information like passwords.
// The function returns a restore function that should be called to restore the
// terminal state.
func DisableInputEcho(ins *streams.In) (restore func() error, err error) {
oldState, err := term.SaveState(ins.FD())
if err != nil {
return nil, err
}
restore = func() error {
return term.RestoreTerminal(ins.FD(), oldState)
}
return restore, term.DisableEcho(ins.FD(), oldState)
return prompt.DisableInputEcho(ins)
}
// PromptForInput requests input from the user.
@ -61,23 +50,7 @@ func DisableInputEcho(ins *streams.In) (restore func() error, err error) {
// the stack and close the io.Reader used for the prompt which will prevent the
// background goroutine from blocking indefinitely.
func PromptForInput(ctx context.Context, in io.Reader, out io.Writer, message string) (string, error) {
_, _ = fmt.Fprint(out, message)
result := make(chan string)
go func() {
scanner := bufio.NewScanner(in)
if scanner.Scan() {
result <- strings.TrimSpace(scanner.Text())
}
}()
select {
case <-ctx.Done():
_, _ = fmt.Fprintln(out, "")
return "", ErrPromptTerminated
case r := <-result:
return r, nil
}
return prompt.ReadInput(ctx, in, out, message)
}
// PromptForConfirmation requests and checks confirmation from the user.
@ -91,39 +64,7 @@ func PromptForInput(ctx context.Context, in io.Reader, out io.Writer, message st
// the stack and close the io.Reader used for the prompt which will prevent the
// background goroutine from blocking indefinitely.
func PromptForConfirmation(ctx context.Context, ins io.Reader, outs io.Writer, message string) (bool, error) {
if message == "" {
message = "Are you sure you want to proceed?"
}
message += " [y/N] "
_, _ = fmt.Fprint(outs, message)
// On Windows, force the use of the regular OS stdin stream.
if runtime.GOOS == "windows" {
ins = streams.NewIn(os.Stdin)
}
result := make(chan bool)
go func() {
var res bool
scanner := bufio.NewScanner(ins)
if scanner.Scan() {
answer := strings.TrimSpace(scanner.Text())
if strings.EqualFold(answer, "y") {
res = true
}
}
result <- res
}()
select {
case <-ctx.Done():
_, _ = fmt.Fprintln(outs, "")
return false, ErrPromptTerminated
case r := <-result:
return r, nil
}
return prompt.Confirm(ctx, ins, outs, message)
}
// PruneFilters merges prune filters specified in config.json with those specified

View File

@ -1,23 +1,12 @@
package command_test
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/internal/test"
"gotest.tools/v3/assert"
)
@ -54,171 +43,3 @@ func TestValidateOutputPath(t *testing.T) {
})
}
}
func TestPromptForInput(t *testing.T) {
t.Run("cancelling the context", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
reader, _ := io.Pipe()
buf := new(bytes.Buffer)
bufioWriter := bufio.NewWriter(buf)
wroteHook := make(chan struct{}, 1)
promptOut := test.NewWriterWithHook(bufioWriter, func(p []byte) {
wroteHook <- struct{}{}
})
promptErr := make(chan error, 1)
go func() {
_, err := command.PromptForInput(ctx, streams.NewIn(reader), streams.NewOut(promptOut), "Enter something")
promptErr <- err
}()
select {
case <-time.After(1 * time.Second):
t.Fatal("timeout waiting for prompt to write to buffer")
case <-wroteHook:
cancel()
}
select {
case <-time.After(1 * time.Second):
t.Fatal("timeout waiting for prompt to be canceled")
case err := <-promptErr:
assert.ErrorIs(t, err, command.ErrPromptTerminated)
}
})
t.Run("user input should be properly trimmed", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
t.Cleanup(cancel)
reader, writer := io.Pipe()
buf := new(bytes.Buffer)
bufioWriter := bufio.NewWriter(buf)
wroteHook := make(chan struct{}, 1)
promptOut := test.NewWriterWithHook(bufioWriter, func(p []byte) {
wroteHook <- struct{}{}
})
go func() {
<-wroteHook
writer.Write([]byte(" foo \n"))
}()
answer, err := command.PromptForInput(ctx, streams.NewIn(reader), streams.NewOut(promptOut), "Enter something")
assert.NilError(t, err)
assert.Equal(t, answer, "foo")
})
}
func TestPromptForConfirmation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
type promptResult struct {
result bool
err error
}
buf := new(bytes.Buffer)
bufioWriter := bufio.NewWriter(buf)
var (
promptWriter *io.PipeWriter
promptReader *io.PipeReader
)
defer func() {
if promptWriter != nil {
promptWriter.Close()
}
if promptReader != nil {
promptReader.Close()
}
}()
for _, tc := range []struct {
desc string
f func() error
expected promptResult
}{
{"SIGINT", func() error {
syscall.Kill(syscall.Getpid(), syscall.SIGINT)
return nil
}, promptResult{false, command.ErrPromptTerminated}},
{"no", func() error {
_, err := fmt.Fprintln(promptWriter, "n")
return err
}, promptResult{false, nil}},
{"yes", func() error {
_, err := fmt.Fprintln(promptWriter, "y")
return err
}, promptResult{true, nil}},
{"any", func() error {
_, err := fmt.Fprintln(promptWriter, "a")
return err
}, promptResult{false, nil}},
{"with space", func() error {
_, err := fmt.Fprintln(promptWriter, " y")
return err
}, promptResult{true, nil}},
{"reader closed", func() error {
return promptReader.Close()
}, promptResult{false, nil}},
} {
t.Run(tc.desc, func(t *testing.T) {
notifyCtx, notifyCancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
t.Cleanup(notifyCancel)
buf.Reset()
promptReader, promptWriter = io.Pipe()
wroteHook := make(chan struct{}, 1)
promptOut := test.NewWriterWithHook(bufioWriter, func(p []byte) {
wroteHook <- struct{}{}
})
result := make(chan promptResult, 1)
go func() {
r, err := command.PromptForConfirmation(notifyCtx, promptReader, promptOut, "")
result <- promptResult{r, err}
}()
select {
case <-time.After(100 * time.Millisecond):
case <-wroteHook:
}
assert.NilError(t, bufioWriter.Flush())
assert.Equal(t, strings.TrimSpace(buf.String()), "Are you sure you want to proceed? [y/N]")
// wait for the Prompt to write to the buffer
drainChannel(ctx, wroteHook)
assert.NilError(t, tc.f())
select {
case <-time.After(500 * time.Millisecond):
t.Fatal("timeout waiting for prompt result")
case r := <-result:
assert.Equal(t, r, tc.expected)
}
})
}
}
func drainChannel(ctx context.Context, ch <-chan struct{}) {
go func() {
for {
select {
case <-ctx.Done():
return
case <-ch:
}
}
}()
}

View File

@ -7,6 +7,7 @@ import (
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/internal/prompt"
"github.com/docker/cli/opts"
"github.com/docker/docker/api/types/versions"
"github.com/docker/docker/errdefs"
@ -77,7 +78,7 @@ func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions)
warning = allVolumesWarning
}
if !options.force {
r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), warning)
r, err := prompt.Confirm(ctx, dockerCli.In(), dockerCli.Out(), warning)
if err != nil {
return 0, "", err
}

116
internal/prompt/prompt.go Normal file
View File

@ -0,0 +1,116 @@
// Package prompt provides utilities to prompt the user for input.
package prompt
import (
"bufio"
"context"
"io"
"os"
"runtime"
"strings"
"github.com/docker/cli/cli/streams"
"github.com/moby/term"
)
const ErrTerminated cancelledErr = "prompt terminated"
type cancelledErr string
func (e cancelledErr) Error() string {
return string(e)
}
func (cancelledErr) Cancelled() {}
// DisableInputEcho disables input echo on the provided streams.In.
// This is useful when the user provides sensitive information like passwords.
// The function returns a restore function that should be called to restore the
// terminal state.
//
// TODO(thaJeztah): implement without depending on streams?
func DisableInputEcho(ins *streams.In) (restore func() error, _ error) {
oldState, err := term.SaveState(ins.FD())
if err != nil {
return nil, err
}
restore = func() error {
return term.RestoreTerminal(ins.FD(), oldState)
}
return restore, term.DisableEcho(ins.FD(), oldState)
}
// ReadInput requests input from the user.
//
// It returns an empty string ("") with an [ErrTerminated] if the user terminates
// the CLI with SIGINT or SIGTERM while the prompt is active. If the prompt
// returns an error, the caller should close the [io.Reader] used for the prompt
// and propagate the error up the stack to prevent the background goroutine
// from blocking indefinitely.
func ReadInput(ctx context.Context, in io.Reader, out io.Writer, message string) (string, error) {
_, _ = out.Write([]byte(message))
result := make(chan string)
go func() {
scanner := bufio.NewScanner(in)
if scanner.Scan() {
result <- strings.TrimSpace(scanner.Text())
}
}()
select {
case <-ctx.Done():
_, _ = out.Write([]byte("\n"))
return "", ErrTerminated
case r := <-result:
return r, nil
}
}
// Confirm requests and checks confirmation from the user.
//
// It displays the provided message followed by "[y/N]". If the user
// input 'y' or 'Y' it returns true otherwise false. If no message is provided,
// "Are you sure you want to proceed? [y/N] " will be used instead.
//
// It returns false with an [ErrTerminated] if the user terminates
// the CLI with SIGINT or SIGTERM while the prompt is active. If the prompt
// returns an error, the caller should close the [io.Reader] used for the prompt
// and propagate the error up the stack to prevent the background goroutine
// from blocking indefinitely.
func Confirm(ctx context.Context, in io.Reader, out io.Writer, message string) (bool, error) {
if message == "" {
message = "Are you sure you want to proceed?"
}
message += " [y/N] "
_, _ = out.Write([]byte(message))
// On Windows, force the use of the regular OS stdin stream.
if runtime.GOOS == "windows" {
in = streams.NewIn(os.Stdin)
}
result := make(chan bool)
go func() {
var res bool
scanner := bufio.NewScanner(in)
if scanner.Scan() {
answer := strings.TrimSpace(scanner.Text())
if strings.EqualFold(answer, "y") {
res = true
}
}
result <- res
}()
select {
case <-ctx.Done():
_, _ = out.Write([]byte("\n"))
return false, ErrTerminated
case r := <-result:
return r, nil
}
}

View File

@ -0,0 +1,211 @@
package prompt_test
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os/signal"
"strings"
"syscall"
"testing"
"time"
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/internal/prompt"
"github.com/docker/cli/internal/test"
"gotest.tools/v3/assert"
)
func TestReadInput(t *testing.T) {
t.Run("cancelling the context", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
reader, _ := io.Pipe()
buf := new(bytes.Buffer)
bufioWriter := bufio.NewWriter(buf)
wroteHook := make(chan struct{}, 1)
promptOut := test.NewWriterWithHook(bufioWriter, func(p []byte) {
wroteHook <- struct{}{}
})
promptErr := make(chan error, 1)
go func() {
_, err := prompt.ReadInput(ctx, streams.NewIn(reader), streams.NewOut(promptOut), "Enter something")
promptErr <- err
}()
select {
case <-time.After(1 * time.Second):
t.Fatal("timeout waiting for prompt to write to buffer")
case <-wroteHook:
cancel()
}
select {
case <-time.After(1 * time.Second):
t.Fatal("timeout waiting for prompt to be canceled")
case err := <-promptErr:
assert.ErrorIs(t, err, prompt.ErrTerminated)
}
})
t.Run("user input should be properly trimmed", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
t.Cleanup(cancel)
reader, writer := io.Pipe()
buf := new(bytes.Buffer)
bufioWriter := bufio.NewWriter(buf)
wroteHook := make(chan struct{}, 1)
promptOut := test.NewWriterWithHook(bufioWriter, func(p []byte) {
wroteHook <- struct{}{}
})
go func() {
<-wroteHook
_, _ = writer.Write([]byte(" foo \n"))
}()
answer, err := prompt.ReadInput(ctx, streams.NewIn(reader), streams.NewOut(promptOut), "Enter something")
assert.NilError(t, err)
assert.Equal(t, answer, "foo")
})
}
func TestConfirm(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
type promptResult struct {
result bool
err error
}
buf := new(bytes.Buffer)
bufioWriter := bufio.NewWriter(buf)
var (
promptWriter *io.PipeWriter
promptReader *io.PipeReader
)
defer func() {
if promptWriter != nil {
_ = promptWriter.Close()
}
if promptReader != nil {
_ = promptReader.Close()
}
}()
for _, tc := range []struct {
desc string
f func() error
expected promptResult
}{
{
desc: "SIGINT",
f: func() error {
_ = syscall.Kill(syscall.Getpid(), syscall.SIGINT)
return nil
},
expected: promptResult{false, prompt.ErrTerminated},
},
{
desc: "no",
f: func() error {
_, err := fmt.Fprintln(promptWriter, "n")
return err
},
expected: promptResult{false, nil},
},
{
desc: "yes",
f: func() error {
_, err := fmt.Fprintln(promptWriter, "y")
return err
},
expected: promptResult{true, nil},
},
{
desc: "any",
f: func() error {
_, err := fmt.Fprintln(promptWriter, "a")
return err
},
expected: promptResult{false, nil},
},
{
desc: "with space",
f: func() error {
_, err := fmt.Fprintln(promptWriter, " y")
return err
},
expected: promptResult{true, nil},
},
{
desc: "reader closed",
f: func() error {
return promptReader.Close()
},
expected: promptResult{false, nil},
},
} {
t.Run(tc.desc, func(t *testing.T) {
notifyCtx, notifyCancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
t.Cleanup(notifyCancel)
buf.Reset()
promptReader, promptWriter = io.Pipe()
wroteHook := make(chan struct{}, 1)
promptOut := test.NewWriterWithHook(bufioWriter, func(p []byte) {
wroteHook <- struct{}{}
})
result := make(chan promptResult, 1)
go func() {
r, err := prompt.Confirm(notifyCtx, promptReader, promptOut, "")
result <- promptResult{r, err}
}()
select {
case <-time.After(100 * time.Millisecond):
case <-wroteHook:
}
assert.NilError(t, bufioWriter.Flush())
assert.Equal(t, strings.TrimSpace(buf.String()), "Are you sure you want to proceed? [y/N]")
// wait for the Prompt to write to the buffer
drainChannel(ctx, wroteHook)
assert.NilError(t, tc.f())
select {
case <-time.After(500 * time.Millisecond):
t.Fatal("timeout waiting for prompt result")
case r := <-result:
assert.Equal(t, r, tc.expected)
}
})
}
}
func drainChannel(ctx context.Context, ch <-chan struct{}) {
go func() {
for {
select {
case <-ctx.Done():
return
case <-ch:
}
}
}()
}

View File

@ -6,8 +6,8 @@ import (
"testing"
"time"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/internal/prompt"
"github.com/spf13/cobra"
"gotest.tools/v3/assert"
)
@ -76,6 +76,6 @@ func TerminatePrompt(ctx context.Context, t *testing.T, cmd *cobra.Command, cli
t.Logf("command stderr:\n%s\n", cli.ErrBuffer().String())
t.Fatalf("command %s did not return after SIGINT", cmd.Name())
case err := <-errChan:
assert.ErrorIs(t, err, command.ErrPromptTerminated)
assert.ErrorIs(t, err, prompt.ErrTerminated)
}
}