Skip to content

Commit d00ca1a

Browse files
tothszabigodrei
andauthored
[CI-267] Command errors (#174)
* Update command errors * Add nil dereference prevention * Extend error wrapping to all of the command run functions * Update one of the test cases to be linux compatible * Update the rest of the test cases to be linux friendly * Use ont the fly error collection * Unexport method * Fix test * Add a default instruction Co-authored-by: Krisztián Gödrei <[email protected]>
1 parent ab31edd commit d00ca1a

File tree

4 files changed

+291
-11
lines changed

4 files changed

+291
-11
lines changed

command/command.go

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package command
22

33
import (
4+
"errors"
5+
"fmt"
46
"io"
57
"os/exec"
68
"strconv"
@@ -9,13 +11,17 @@ import (
911
"github.com/bitrise-io/go-utils/v2/env"
1012
)
1113

14+
// ErrorFinder ...
15+
type ErrorFinder func(out string) []string
16+
1217
// Opts ...
1318
type Opts struct {
14-
Stdout io.Writer
15-
Stderr io.Writer
16-
Stdin io.Reader
17-
Env []string
18-
Dir string
19+
Stdout io.Writer
20+
Stderr io.Writer
21+
Stdin io.Reader
22+
Env []string
23+
Dir string
24+
ErrorFinder ErrorFinder
1925
}
2026

2127
// Factory ...
@@ -35,7 +41,13 @@ func NewFactory(envRepository env.Repository) Factory {
3541
// Create ...
3642
func (f factory) Create(name string, args []string, opts *Opts) Command {
3743
cmd := exec.Command(name, args...)
44+
var collector *errorCollector
45+
3846
if opts != nil {
47+
if opts.ErrorFinder != nil {
48+
collector = &errorCollector{errorFinder: opts.ErrorFinder}
49+
}
50+
3951
cmd.Stdout = opts.Stdout
4052
cmd.Stderr = opts.Stderr
4153
cmd.Stdin = opts.Stdin
@@ -47,7 +59,10 @@ func (f factory) Create(name string, args []string, opts *Opts) Command {
4759
cmd.Env = append(f.envRepository.List(), opts.Env...)
4860
cmd.Dir = opts.Dir
4961
}
50-
return command{cmd}
62+
return &command{
63+
cmd: cmd,
64+
errorCollector: collector,
65+
}
5166
}
5267

5368
// Command ...
@@ -62,7 +77,8 @@ type Command interface {
6277
}
6378

6479
type command struct {
65-
cmd *exec.Cmd
80+
cmd *exec.Cmd
81+
errorCollector *errorCollector
6682
}
6783

6884
// PrintableCommandArgs ...
@@ -71,13 +87,24 @@ func (c command) PrintableCommandArgs() string {
7187
}
7288

7389
// Run ...
74-
func (c command) Run() error {
75-
return c.cmd.Run()
90+
func (c *command) Run() error {
91+
c.wrapOutputs()
92+
93+
if err := c.cmd.Run(); err != nil {
94+
return c.wrapError(err)
95+
}
96+
97+
return nil
7698
}
7799

78100
// RunAndReturnExitCode ...
79101
func (c command) RunAndReturnExitCode() (int, error) {
102+
c.wrapOutputs()
80103
err := c.cmd.Run()
104+
if err != nil {
105+
err = c.wrapError(err)
106+
}
107+
81108
exitCode := c.cmd.ProcessState.ExitCode()
82109
return exitCode, err
83110
}
@@ -86,24 +113,44 @@ func (c command) RunAndReturnExitCode() (int, error) {
86113
func (c command) RunAndReturnTrimmedOutput() (string, error) {
87114
outBytes, err := c.cmd.Output()
88115
outStr := string(outBytes)
116+
if err != nil {
117+
if c.errorCollector != nil {
118+
c.errorCollector.collectErrors(outStr)
119+
}
120+
err = c.wrapError(err)
121+
}
122+
89123
return strings.TrimSpace(outStr), err
90124
}
91125

92126
// RunAndReturnTrimmedCombinedOutput ...
93127
func (c command) RunAndReturnTrimmedCombinedOutput() (string, error) {
94128
outBytes, err := c.cmd.CombinedOutput()
95129
outStr := string(outBytes)
130+
if err != nil {
131+
if c.errorCollector != nil {
132+
c.errorCollector.collectErrors(outStr)
133+
}
134+
err = c.wrapError(err)
135+
}
136+
96137
return strings.TrimSpace(outStr), err
97138
}
98139

99140
// Start ...
100141
func (c command) Start() error {
142+
c.wrapOutputs()
101143
return c.cmd.Start()
102144
}
103145

104146
// Wait ...
105147
func (c command) Wait() error {
106-
return c.cmd.Wait()
148+
err := c.cmd.Wait()
149+
if err != nil {
150+
err = c.wrapError(err)
151+
}
152+
153+
return err
107154
}
108155

109156
func printableCommandArgs(isQuoteFirst bool, fullCommandArgs []string) string {
@@ -118,3 +165,34 @@ func printableCommandArgs(isQuoteFirst bool, fullCommandArgs []string) string {
118165

119166
return strings.Join(cmdArgsDecorated, " ")
120167
}
168+
169+
func (c command) wrapError(err error) error {
170+
var exitErr *exec.ExitError
171+
if errors.As(err, &exitErr) {
172+
if c.errorCollector != nil && len(c.errorCollector.errorLines) > 0 {
173+
return fmt.Errorf("command failed with exit status %d (%s): %w", exitErr.ExitCode(), c.PrintableCommandArgs(), errors.New(strings.Join(c.errorCollector.errorLines, "\n")))
174+
}
175+
return fmt.Errorf("command failed with exit status %d (%s): %w", exitErr.ExitCode(), c.PrintableCommandArgs(), errors.New("check the command's output for details"))
176+
}
177+
return fmt.Errorf("executing command failed (%s): %w", c.PrintableCommandArgs(), err)
178+
}
179+
180+
func (c command) wrapOutputs() {
181+
if c.errorCollector == nil {
182+
return
183+
}
184+
185+
if c.cmd.Stdout != nil {
186+
outWriter := io.MultiWriter(c.errorCollector, c.cmd.Stdout)
187+
c.cmd.Stdout = outWriter
188+
} else {
189+
c.cmd.Stdout = c.errorCollector
190+
}
191+
192+
if c.cmd.Stderr != nil {
193+
errWriter := io.MultiWriter(c.errorCollector, c.cmd.Stderr)
194+
c.cmd.Stderr = errWriter
195+
} else {
196+
c.cmd.Stderr = c.errorCollector
197+
}
198+
}

command/command_test.go

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,75 @@
11
package command
22

33
import (
4-
"github.com/bitrise-io/go-utils/v2/env"
4+
"bytes"
5+
"os/exec"
6+
"strings"
57
"testing"
8+
9+
"github.com/bitrise-io/go-utils/v2/env"
610
)
711

12+
func TestRunErrors(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
cmd command
16+
wantErr string
17+
}{
18+
{
19+
name: "command without stdout set",
20+
cmd: command{cmd: exec.Command("bash", "testdata/exit_with_message.sh")},
21+
wantErr: `command failed with exit status 1 (bash "testdata/exit_with_message.sh"): check the command's output for details`,
22+
},
23+
{
24+
name: "command with stdout set",
25+
cmd: func() command {
26+
c := exec.Command("bash", "testdata/exit_with_message.sh")
27+
var out bytes.Buffer
28+
c.Stdout = &out
29+
return command{cmd: c}
30+
}(),
31+
wantErr: `command failed with exit status 1 (bash "testdata/exit_with_message.sh"): check the command's output for details`,
32+
},
33+
{
34+
name: "command with error finder",
35+
cmd: func() command {
36+
c := exec.Command("bash", "testdata/exit_with_message.sh")
37+
errorFinder := func(out string) []string {
38+
var errors []string
39+
for _, line := range strings.Split(out, "\n") {
40+
if strings.Contains(line, "Error:") {
41+
errors = append(errors, line)
42+
}
43+
}
44+
return errors
45+
}
46+
47+
return command{
48+
cmd: c,
49+
errorCollector: &errorCollector{errorFinder: errorFinder},
50+
}
51+
}(),
52+
wantErr: `command failed with exit status 1 (bash "testdata/exit_with_message.sh"): Error: first error
53+
Error: second error
54+
Error: third error
55+
Error: fourth error`,
56+
},
57+
}
58+
for _, tt := range tests {
59+
t.Run(tt.name, func(t *testing.T) {
60+
err := tt.cmd.Run()
61+
var gotErrMsg string
62+
if err != nil {
63+
gotErrMsg = err.Error()
64+
}
65+
if gotErrMsg != tt.wantErr {
66+
t.Errorf("command.Run() error = %v, wantErr %v", gotErrMsg, tt.wantErr)
67+
return
68+
}
69+
})
70+
}
71+
}
72+
873
func TestRunCmdAndReturnExitCode(t *testing.T) {
974
type args struct {
1075
cmd Command
@@ -62,3 +127,113 @@ func TestRunCmdAndReturnExitCode(t *testing.T) {
62127
})
63128
}
64129
}
130+
131+
func TestRunAndReturnTrimmedOutput(t *testing.T) {
132+
tests := []struct {
133+
name string
134+
cmd command
135+
wantErr string
136+
}{
137+
{
138+
name: "command without error finder",
139+
cmd: func() command {
140+
c := exec.Command("bash", "testdata/exit_with_message.sh")
141+
return command{
142+
cmd: c,
143+
}
144+
}(),
145+
wantErr: "command failed with exit status 1 (bash \"testdata/exit_with_message.sh\"): check the command's output for details",
146+
},
147+
{
148+
name: "command with error finder",
149+
cmd: func() command {
150+
c := exec.Command("bash", "testdata/exit_with_message.sh")
151+
errorFinder := func(out string) []string {
152+
var errors []string
153+
for _, line := range strings.Split(out, "\n") {
154+
if strings.Contains(line, "Error:") {
155+
errors = append(errors, line)
156+
}
157+
}
158+
return errors
159+
}
160+
161+
return command{
162+
cmd: c,
163+
errorCollector: &errorCollector{errorFinder: errorFinder},
164+
}
165+
}(),
166+
wantErr: `command failed with exit status 1 (bash "testdata/exit_with_message.sh"): Error: first error
167+
Error: second error`,
168+
},
169+
}
170+
for _, tt := range tests {
171+
t.Run(tt.name, func(t *testing.T) {
172+
_, err := tt.cmd.RunAndReturnTrimmedOutput()
173+
var gotErrMsg string
174+
if err != nil {
175+
gotErrMsg = err.Error()
176+
}
177+
if gotErrMsg != tt.wantErr {
178+
t.Errorf("command.Run() error = %v, wantErr %v", gotErrMsg, tt.wantErr)
179+
return
180+
}
181+
})
182+
}
183+
}
184+
185+
func TestRunAndReturnTrimmedCombinedOutput(t *testing.T) {
186+
tests := []struct {
187+
name string
188+
cmd command
189+
wantErr string
190+
}{
191+
{
192+
name: "command without error finder",
193+
cmd: func() command {
194+
c := exec.Command("bash", "testdata/exit_with_message.sh")
195+
return command{
196+
cmd: c,
197+
}
198+
}(),
199+
wantErr: "command failed with exit status 1 (bash \"testdata/exit_with_message.sh\"): check the command's output for details",
200+
},
201+
{
202+
name: "command with error finder",
203+
cmd: func() command {
204+
c := exec.Command("bash", "testdata/exit_with_message.sh")
205+
errorFinder := func(out string) []string {
206+
var errors []string
207+
for _, line := range strings.Split(out, "\n") {
208+
if strings.Contains(line, "Error:") {
209+
errors = append(errors, line)
210+
}
211+
}
212+
return errors
213+
}
214+
215+
return command{
216+
cmd: c,
217+
errorCollector: &errorCollector{errorFinder: errorFinder},
218+
}
219+
}(),
220+
wantErr: `command failed with exit status 1 (bash "testdata/exit_with_message.sh"): Error: first error
221+
Error: second error
222+
Error: third error
223+
Error: fourth error`,
224+
},
225+
}
226+
for _, tt := range tests {
227+
t.Run(tt.name, func(t *testing.T) {
228+
_, err := tt.cmd.RunAndReturnTrimmedCombinedOutput()
229+
var gotErrMsg string
230+
if err != nil {
231+
gotErrMsg = err.Error()
232+
}
233+
if gotErrMsg != tt.wantErr {
234+
t.Errorf("command.Run() error = %v, wantErr %v", gotErrMsg, tt.wantErr)
235+
return
236+
}
237+
})
238+
}
239+
}

command/errorcollector.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package command
2+
3+
type errorCollector struct {
4+
errorLines []string
5+
errorFinder ErrorFinder
6+
}
7+
8+
func (e *errorCollector) Write(p []byte) (n int, err error) {
9+
e.collectErrors(string(p))
10+
return len(p), nil
11+
}
12+
13+
func (e *errorCollector) collectErrors(output string) {
14+
lines := e.errorFinder(output)
15+
if len(lines) > 0 {
16+
e.errorLines = append(e.errorLines, lines...)
17+
}
18+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/usr/bin/env bash
2+
# These messages are written to stdout
3+
echo Error: first error
4+
echo Error: second error
5+
echo This is not an stdout error
6+
# These messages are written to stderr
7+
echo Error: third error >&2
8+
echo Error: fourth error >&2
9+
exit 1

0 commit comments

Comments
 (0)