Skip to content

Commit 81915c5

Browse files
committed
cmd/docker: extract printCommandError helper and add tests
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>
1 parent c373c61 commit 81915c5

2 files changed

Lines changed: 115 additions & 7 deletions

File tree

cmd/docker/docker.go

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"context"
88
"errors"
99
"fmt"
10+
"io"
1011
"os"
1112
"os/exec"
1213
"os/signal"
@@ -133,6 +134,34 @@ func cmdErrorMessage(err error) string {
133134
return fmt.Sprintf("exited with code %d", getExitCode(err))
134135
}
135136

137+
// printCommandError prints err to stderr before plugin hooks run, so that
138+
// hook output (such as the "What's next:" hint) is rendered after the
139+
// command's own error output instead of before it.
140+
//
141+
// Errors caused by context cancellation, user-initiated signal termination,
142+
// or errors with an empty message are not printed (matching the conditions
143+
// used in [main]).
144+
//
145+
// If err was printed, it is replaced with a status only [cli.StatusError]
146+
// preserving the exit code, so that [main] does not print the same message
147+
// a second time.
148+
func printCommandError(stderr io.Writer, err error) error {
149+
if err == nil {
150+
return nil
151+
}
152+
if errdefs.IsCanceled(err) {
153+
return err
154+
}
155+
if errors.As(err, &errCtxSignalTerminated{}) {
156+
return err
157+
}
158+
if err.Error() == "" {
159+
return err
160+
}
161+
_, _ = fmt.Fprintln(stderr, err)
162+
return cli.StatusError{StatusCode: getExitCode(err)}
163+
}
164+
136165
func newDockerCommand(dockerCli *command.DockerCli) *cli.TopLevelCommand {
137166
var (
138167
opts *cliflags.ClientOptions
@@ -543,19 +572,14 @@ func runDocker(ctx context.Context, dockerCli *command.DockerCli) error {
543572
cmd.SetArgs(args)
544573
err = cmd.ExecuteContext(ctx)
545574

546-
// Capture the error message before we may consume the error below;
575+
// Capture the error message before printCommandError may replace err;
547576
// plugin hooks still need to see the original message.
548577
errMessage := cmdErrorMessage(err)
549578

550579
// Print the command's error before invoking plugin hooks so that the
551580
// "What's next" hint (and any other hook output) is rendered after
552581
// the command's error output, not before it.
553-
if err != nil && !errdefs.IsCanceled(err) && !errors.As(err, &errCtxSignalTerminated{}) && err.Error() != "" {
554-
_, _ = fmt.Fprintln(dockerCli.Err(), err)
555-
// Replace the error with a status-only one so that main()
556-
// does not print the same message a second time.
557-
err = cli.StatusError{StatusCode: getExitCode(err)}
558-
}
582+
err = printCommandError(dockerCli.Err(), err)
559583

560584
// If the command is being executed in an interactive terminal
561585
// and hook are enabled, run the plugin hooks.

cmd/docker/docker_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,90 @@ func TestGetExitCode(t *testing.T) {
163163
})
164164
}
165165

166+
func TestPrintCommandError(t *testing.T) {
167+
t.Run("nil error returns nil and writes nothing", func(t *testing.T) {
168+
var buf bytes.Buffer
169+
got := printCommandError(&buf, nil)
170+
assert.NilError(t, got)
171+
assert.Equal(t, buf.String(), "")
172+
})
173+
174+
t.Run("generic error is printed and replaced with StatusError", func(t *testing.T) {
175+
var buf bytes.Buffer
176+
orig := errors.New("docker: open ./no-such-file: no such file or directory")
177+
got := printCommandError(&buf, orig)
178+
179+
// The original message is written to stderr before hooks run
180+
assert.Equal(t, buf.String(), orig.Error()+"\n")
181+
182+
// and the returned error is a status-only StatusError so
183+
// main() does not print the same message a second time.
184+
var st dockercli.StatusError
185+
assert.Assert(t, errors.As(got, &st))
186+
assert.Equal(t, st.Status, "")
187+
assert.Equal(t, st.StatusCode, 1)
188+
})
189+
190+
t.Run("StatusError with message preserves exit code and prints message", func(t *testing.T) {
191+
var buf bytes.Buffer
192+
orig := dockercli.StatusError{Status: "build failed", StatusCode: 125}
193+
got := printCommandError(&buf, orig)
194+
195+
assert.Equal(t, buf.String(), "build failed\n")
196+
197+
var st dockercli.StatusError
198+
assert.Assert(t, errors.As(got, &st))
199+
assert.Equal(t, st.StatusCode, 125)
200+
// The replacement is status-only; the message field is cleared
201+
// because we already printed it ourselves.
202+
assert.Equal(t, st.Status, "")
203+
})
204+
205+
t.Run("StatusError with only exit code is not printed", func(t *testing.T) {
206+
var buf bytes.Buffer
207+
got := printCommandError(&buf, dockercli.StatusError{StatusCode: 42})
208+
209+
// main() also skips printing exit-code-only StatusErrors, so we
210+
// must not print it here either, and the exit code must propagate.
211+
assert.Equal(t, buf.String(), "")
212+
213+
var st dockercli.StatusError
214+
assert.Assert(t, errors.As(got, &st))
215+
assert.Equal(t, st.StatusCode, 42)
216+
assert.Equal(t, st.Status, "")
217+
})
218+
219+
t.Run("canceled error is not printed and not replaced", func(t *testing.T) {
220+
var buf bytes.Buffer
221+
got := printCommandError(&buf, context.Canceled)
222+
223+
assert.Equal(t, buf.String(), "")
224+
// If it had been replaced with a StatusError, errors.Is would
225+
// return false; this asserts the error is propagated as-is.
226+
assert.ErrorIs(t, got, context.Canceled)
227+
})
228+
229+
t.Run("wrapped canceled error is not printed and not replaced", func(t *testing.T) {
230+
var buf bytes.Buffer
231+
got := printCommandError(&buf, fmt.Errorf("wrapped: %w", context.Canceled))
232+
233+
assert.Equal(t, buf.String(), "")
234+
assert.ErrorIs(t, got, context.Canceled)
235+
})
236+
237+
t.Run("signal-terminated error is not printed and not replaced", func(t *testing.T) {
238+
var buf bytes.Buffer
239+
orig := errCtxSignalTerminated{signal: syscall.SIGINT}
240+
got := printCommandError(&buf, orig)
241+
242+
assert.Equal(t, buf.String(), "")
243+
244+
var sig errCtxSignalTerminated
245+
assert.Assert(t, errors.As(got, &sig))
246+
assert.Equal(t, sig, orig)
247+
})
248+
}
249+
166250
func TestCmdErrorMessage(t *testing.T) {
167251
t.Run("nil error returns empty string", func(t *testing.T) {
168252
assert.Equal(t, cmdErrorMessage(nil), "")

0 commit comments

Comments
 (0)