Files
cli/cmd/docker/docker_test.go
Mohammed Olabie cffd24dea4 cmd/docker: print command error before running plugin hooks
Plugin hook output (such as Gordon's "What's next:" hint) was rendered
before the command's own error message because hooks were invoked inside
runDocker while the error was only printed in main() after runDocker
returned.

Print the error to stderr before invoking the hooks, and replace the
error with a status-only StatusError so main() does not print the same
message a second time. The original error message is captured up-front
and still passed to the plugin hooks.

The inline error-printing block in runDocker is extracted into a
printCommandError helper so the new behavior can be unit-tested
directly without spinning up runDocker.

TestPrintCommandError covers each branch of the helper: nil error,
generic error (printed and replaced with StatusError), StatusError
with/without message, context.Canceled (raw and wrapped), and
errCtxSignalTerminated.

Signed-off-by: Mohammed Olabie <olabiedev@gmail.com>
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-08-31 19:20:27 +02:00

286 lines
8.0 KiB
Go

package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"syscall"
"testing"
"time"
dockercli "github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/commands"
"github.com/docker/cli/cli/debug"
platformsignals "github.com/docker/cli/cmd/docker/internal/signals"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)
func TestDisableFlagsInUseLineIsSet(t *testing.T) {
dockerCli, err := command.NewDockerCli(command.WithBaseContext(context.TODO()))
assert.NilError(t, err)
rootCmd := &cobra.Command{DisableFlagsInUseLine: true}
commands.AddCommands(rootCmd, dockerCli)
var errs []error
visitAll(rootCmd, func(c *cobra.Command) {
if !c.DisableFlagsInUseLine {
errs = append(errs, errors.New("DisableFlagsInUseLine is not set for "+c.CommandPath()))
}
})
err = errors.Join(errs...)
assert.NilError(t, err)
}
func TestClientDebugEnabled(t *testing.T) {
defer debug.Disable()
ctx := t.Context()
cli, err := command.NewDockerCli(command.WithBaseContext(ctx))
assert.NilError(t, err)
tcmd := newDockerCommand(cli)
tcmd.SetFlag("debug", "true")
cmd, _, err := tcmd.HandleGlobalFlags()
assert.NilError(t, err)
assert.NilError(t, tcmd.Initialize())
err = cmd.PersistentPreRunE(cmd, []string{})
assert.NilError(t, err)
assert.Check(t, is.Equal("1", os.Getenv("DEBUG")))
assert.Check(t, is.Equal(logrus.DebugLevel, logrus.GetLevel()))
}
var discard = io.NopCloser(bytes.NewBuffer(nil))
func runCliCommand(t *testing.T, r io.ReadCloser, w io.Writer, args ...string) error {
t.Helper()
if r == nil {
r = discard
}
if w == nil {
w = io.Discard
}
ctx, cancel := context.WithCancel(context.TODO())
defer cancel()
cli, err := command.NewDockerCli(
command.WithBaseContext(ctx),
command.WithInputStream(r),
command.WithCombinedStreams(w))
assert.NilError(t, err)
tcmd := newDockerCommand(cli)
tcmd.SetArgs(args)
cmd, _, err := tcmd.HandleGlobalFlags()
assert.NilError(t, err)
assert.NilError(t, tcmd.Initialize())
return cmd.Execute()
}
func TestExitStatusForInvalidSubcommandWithHelpFlag(t *testing.T) {
err := runCliCommand(t, nil, nil, "help", "invalid")
assert.Error(t, err, "unknown help topic: invalid")
}
func TestExitStatusForInvalidSubcommand(t *testing.T) {
err := runCliCommand(t, nil, nil, "invalid")
assert.Check(t, is.ErrorContains(err, "docker: unknown command: docker invalid"))
}
func TestVersion(t *testing.T) {
var b bytes.Buffer
err := runCliCommand(t, nil, &b, "--version")
assert.NilError(t, err)
assert.Check(t, is.Contains(b.String(), "Docker version"))
}
func TestUserTerminatedError(t *testing.T) {
ctx, cancel := context.WithTimeoutCause(context.Background(), time.Second*1, errors.New("test timeout"))
t.Cleanup(cancel)
notifyCtx, cancelNotify := notifyContext(ctx, platformsignals.TerminationSignals...)
t.Cleanup(cancelNotify)
assert.Check(t, syscall.Kill(syscall.Getpid(), syscall.SIGINT))
<-notifyCtx.Done()
assert.ErrorIs(t, context.Cause(notifyCtx), errCtxSignalTerminated{
signal: syscall.SIGINT,
})
assert.Equal(t, getExitCode(context.Cause(notifyCtx)), 130)
notifyCtx, cancelNotify = notifyContext(ctx, platformsignals.TerminationSignals...)
t.Cleanup(cancelNotify)
assert.Check(t, syscall.Kill(syscall.Getpid(), syscall.SIGTERM))
<-notifyCtx.Done()
assert.ErrorIs(t, context.Cause(notifyCtx), errCtxSignalTerminated{
signal: syscall.SIGTERM,
})
assert.Equal(t, getExitCode(context.Cause(notifyCtx)), 143)
}
func TestGetExitCode(t *testing.T) {
t.Run("nil error returns 0", func(t *testing.T) {
assert.Equal(t, getExitCode(nil), 0)
})
t.Run("generic error returns 1", func(t *testing.T) {
assert.Equal(t, getExitCode(errors.New("some failure")), 1)
})
t.Run("StatusError with code", func(t *testing.T) {
err := dockercli.StatusError{StatusCode: 42}
assert.Equal(t, getExitCode(err), 42)
})
t.Run("StatusError with zero code falls back to 1", func(t *testing.T) {
err := dockercli.StatusError{StatusCode: 0, Status: "something went wrong"}
assert.Equal(t, getExitCode(err), 1)
})
t.Run("wrapped StatusError", func(t *testing.T) {
err := fmt.Errorf("wrapper: %w", dockercli.StatusError{StatusCode: 99})
assert.Equal(t, getExitCode(err), 99)
})
t.Run("SIGINT returns 130", func(t *testing.T) {
err := errCtxSignalTerminated{signal: syscall.SIGINT}
assert.Equal(t, getExitCode(err), 130)
})
t.Run("SIGTERM returns 143", func(t *testing.T) {
err := errCtxSignalTerminated{signal: syscall.SIGTERM}
assert.Equal(t, getExitCode(err), 143)
})
}
func TestPrintCommandError(t *testing.T) {
t.Run("nil error", func(t *testing.T) {
var buf bytes.Buffer
got := printCommandError(&buf, nil)
assert.NilError(t, got)
assert.Equal(t, buf.String(), "")
})
t.Run("generic error", func(t *testing.T) {
var buf bytes.Buffer
orig := errors.New("docker: open ./no-such-file: no such file or directory")
got := printCommandError(&buf, orig)
assert.Equal(t, buf.String(), orig.Error()+"\n")
var st dockercli.StatusError
assert.Assert(t, errors.As(got, &st))
assert.Equal(t, st.Status, "")
assert.Equal(t, st.StatusCode, 1)
})
t.Run("StatusError with message", func(t *testing.T) {
var buf bytes.Buffer
got := printCommandError(&buf, dockercli.StatusError{
Status: "build failed",
StatusCode: 125,
})
assert.Equal(t, buf.String(), "build failed\n")
var st dockercli.StatusError
assert.Assert(t, errors.As(got, &st))
assert.Equal(t, st.Status, "")
assert.Equal(t, st.StatusCode, 125)
})
t.Run("StatusError without message", func(t *testing.T) {
var buf bytes.Buffer
got := printCommandError(&buf, dockercli.StatusError{StatusCode: 42})
assert.Equal(t, buf.String(), "")
var st dockercli.StatusError
assert.Assert(t, errors.As(got, &st))
assert.Equal(t, st.Status, "")
assert.Equal(t, st.StatusCode, 42)
})
t.Run("canceled error", func(t *testing.T) {
var buf bytes.Buffer
got := printCommandError(&buf, context.Canceled)
assert.Equal(t, buf.String(), "")
assert.ErrorIs(t, got, context.Canceled)
})
t.Run("wrapped canceled error", func(t *testing.T) {
var buf bytes.Buffer
got := printCommandError(&buf, fmt.Errorf("wrapped: %w", context.Canceled))
assert.Equal(t, buf.String(), "")
assert.ErrorIs(t, got, context.Canceled)
})
t.Run("signal-terminated error", func(t *testing.T) {
var buf bytes.Buffer
orig := errCtxSignalTerminated{signal: syscall.SIGINT}
got := printCommandError(&buf, orig)
assert.Equal(t, buf.String(), "")
var sig errCtxSignalTerminated
assert.Assert(t, errors.As(got, &sig))
assert.Equal(t, sig, orig)
})
}
func TestCmdErrorMessage(t *testing.T) {
t.Run("nil error returns empty string", func(t *testing.T) {
assert.Equal(t, cmdErrorMessage(nil), "")
})
t.Run("generic error returns error message", func(t *testing.T) {
assert.Equal(t, cmdErrorMessage(errors.New("something broke")), "something broke")
})
t.Run("StatusError with Status field", func(t *testing.T) {
err := dockercli.StatusError{Status: "build failed", StatusCode: 1}
assert.Equal(t, cmdErrorMessage(err), "build failed")
})
t.Run("StatusError with only exit code falls back to generic message", func(t *testing.T) {
err := dockercli.StatusError{StatusCode: 42}
assert.Equal(t, cmdErrorMessage(err), "exited with code 42")
})
t.Run("wrapped error preserves message", func(t *testing.T) {
err := fmt.Errorf("wrapper: %w", errors.New("inner failure"))
assert.Equal(t, cmdErrorMessage(err), "wrapper: inner failure")
})
}
func TestVisitAll(t *testing.T) {
root := &cobra.Command{Use: "root"}
sub1 := &cobra.Command{Use: "sub1"}
sub1sub1 := &cobra.Command{Use: "sub1sub1"}
sub1sub2 := &cobra.Command{Use: "sub1sub2"}
sub2 := &cobra.Command{Use: "sub2"}
root.AddCommand(sub1, sub2)
sub1.AddCommand(sub1sub1, sub1sub2)
var visited []string
visitAll(root, func(ccmd *cobra.Command) {
visited = append(visited, ccmd.Name())
})
expected := []string{"sub1sub1", "sub1sub2", "sub1", "sub2", "root"}
assert.DeepEqual(t, expected, visited)
}