/
command.go
290 lines (256 loc) · 6.83 KB
/
command.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
/*Package icmd executes binaries and provides convenient assertions for testing the results.
*/
package icmd // import "gotest.tools/v3/icmd"
import (
"bytes"
"fmt"
"io"
"os"
"strings"
"sync"
"time"
exec "golang.org/x/sys/execabs"
"gotest.tools/v3/assert"
"gotest.tools/v3/assert/cmp"
)
type helperT interface {
Helper()
}
// None is a token to inform Result.Assert that the output should be empty
const None = "[NOTHING]"
type lockedBuffer struct {
m sync.RWMutex
buf bytes.Buffer
}
func (buf *lockedBuffer) Write(b []byte) (int, error) {
buf.m.Lock()
defer buf.m.Unlock()
return buf.buf.Write(b)
}
func (buf *lockedBuffer) String() string {
buf.m.RLock()
defer buf.m.RUnlock()
return buf.buf.String()
}
// Result stores the result of running a command
type Result struct {
Cmd *exec.Cmd
ExitCode int
Error error
// Timeout is true if the command was killed because it ran for too long
Timeout bool
outBuffer *lockedBuffer
errBuffer *lockedBuffer
}
// Assert compares the Result against the Expected struct, and fails the test if
// any of the expectations are not met.
//
// This function is equivalent to assert.Assert(t, result.Equal(exp)).
func (r *Result) Assert(t assert.TestingT, exp Expected) *Result {
if ht, ok := t.(helperT); ok {
ht.Helper()
}
assert.Assert(t, r.Equal(exp))
return r
}
// Equal compares the result to Expected. If the result doesn't match expected
// returns a formatted failure message with the command, stdout, stderr, exit code,
// and any failed expectations.
func (r *Result) Equal(exp Expected) cmp.Comparison {
return func() cmp.Result {
return cmp.ResultFromError(r.match(exp))
}
}
// Compare the result to Expected and return an error if they do not match.
func (r *Result) Compare(exp Expected) error {
return r.match(exp)
}
func (r *Result) match(exp Expected) error {
errors := []string{}
add := func(format string, args ...interface{}) {
errors = append(errors, fmt.Sprintf(format, args...))
}
if exp.ExitCode != r.ExitCode {
add("ExitCode was %d expected %d", r.ExitCode, exp.ExitCode)
}
if exp.Timeout != r.Timeout {
if exp.Timeout {
add("Expected command to timeout")
} else {
add("Expected command to finish, but it hit the timeout")
}
}
if !matchOutput(exp.Out, r.Stdout()) {
add("Expected stdout to contain %q", exp.Out)
}
if !matchOutput(exp.Err, r.Stderr()) {
add("Expected stderr to contain %q", exp.Err)
}
switch {
// If a non-zero exit code is expected there is going to be an error.
// Don't require an error message as well as an exit code because the
// error message is going to be "exit status <code> which is not useful
case exp.Error == "" && exp.ExitCode != 0:
case exp.Error == "" && r.Error != nil:
add("Expected no error")
case exp.Error != "" && r.Error == nil:
add("Expected error to contain %q, but there was no error", exp.Error)
case exp.Error != "" && !strings.Contains(r.Error.Error(), exp.Error):
add("Expected error to contain %q", exp.Error)
}
if len(errors) == 0 {
return nil
}
return fmt.Errorf("%s\nFailures:\n%s", r, strings.Join(errors, "\n"))
}
func matchOutput(expected string, actual string) bool {
switch expected {
case None:
return actual == ""
default:
return strings.Contains(actual, expected)
}
}
func (r *Result) String() string {
var timeout string
if r.Timeout {
timeout = " (timeout)"
}
var errString string
if r.Error != nil {
errString = "\nError: " + r.Error.Error()
}
return fmt.Sprintf(`
Command: %s
ExitCode: %d%s%s
Stdout: %v
Stderr: %v
`,
strings.Join(r.Cmd.Args, " "),
r.ExitCode,
timeout,
errString,
r.Stdout(),
r.Stderr())
}
// Expected is the expected output from a Command. This struct is compared to a
// Result struct by Result.Assert().
type Expected struct {
ExitCode int
Timeout bool
Error string
Out string
Err string
}
// Success is the default expected result. A Success result is one with a 0
// ExitCode.
var Success = Expected{}
// Stdout returns the stdout of the process as a string
func (r *Result) Stdout() string {
return r.outBuffer.String()
}
// Stderr returns the stderr of the process as a string
func (r *Result) Stderr() string {
return r.errBuffer.String()
}
// Combined returns the stdout and stderr combined into a single string
func (r *Result) Combined() string {
return r.outBuffer.String() + r.errBuffer.String()
}
func (r *Result) setExitError(err error) {
if err == nil {
return
}
r.Error = err
r.ExitCode = processExitCode(err)
}
// Cmd contains the arguments and options for a process to run as part of a test
// suite.
type Cmd struct {
Command []string
Timeout time.Duration
Stdin io.Reader
Stdout io.Writer
Dir string
Env []string
ExtraFiles []*os.File
}
// Command create a simple Cmd with the specified command and arguments
func Command(command string, args ...string) Cmd {
return Cmd{Command: append([]string{command}, args...)}
}
// RunCmd runs a command and returns a Result
func RunCmd(cmd Cmd, cmdOperators ...CmdOp) *Result {
for _, op := range cmdOperators {
op(&cmd)
}
result := StartCmd(cmd)
if result.Error != nil {
return result
}
return WaitOnCmd(cmd.Timeout, result)
}
// RunCommand runs a command with default options, and returns a result
func RunCommand(command string, args ...string) *Result {
return RunCmd(Command(command, args...))
}
// StartCmd starts a command, but doesn't wait for it to finish
func StartCmd(cmd Cmd) *Result {
result := buildCmd(cmd)
if result.Error != nil {
return result
}
result.setExitError(result.Cmd.Start())
return result
}
// TODO: support exec.CommandContext
func buildCmd(cmd Cmd) *Result {
var execCmd *exec.Cmd
switch len(cmd.Command) {
case 1:
execCmd = exec.Command(cmd.Command[0])
default:
execCmd = exec.Command(cmd.Command[0], cmd.Command[1:]...)
}
outBuffer := new(lockedBuffer)
errBuffer := new(lockedBuffer)
execCmd.Stdin = cmd.Stdin
execCmd.Dir = cmd.Dir
execCmd.Env = cmd.Env
if cmd.Stdout != nil {
execCmd.Stdout = io.MultiWriter(outBuffer, cmd.Stdout)
} else {
execCmd.Stdout = outBuffer
}
execCmd.Stderr = errBuffer
execCmd.ExtraFiles = cmd.ExtraFiles
return &Result{
Cmd: execCmd,
outBuffer: outBuffer,
errBuffer: errBuffer,
}
}
// WaitOnCmd waits for a command to complete. If timeout is non-nil then
// only wait until the timeout.
func WaitOnCmd(timeout time.Duration, result *Result) *Result {
if timeout == time.Duration(0) {
result.setExitError(result.Cmd.Wait())
return result
}
done := make(chan error, 1)
// Wait for command to exit in a goroutine
go func() {
done <- result.Cmd.Wait()
}()
select {
case <-time.After(timeout):
killErr := result.Cmd.Process.Kill()
if killErr != nil {
fmt.Printf("failed to kill (pid=%d): %v\n", result.Cmd.Process.Pid, killErr)
}
result.Timeout = true
case err := <-done:
result.setExitError(err)
}
return result
}