Skip to content

Commit a7c478f

Browse files
authored
Merge pull request #9 from SimonBaeumer/add-multiplexed-writer
Add multiplexed writer
2 parents 12f730b + 0ba5de6 commit a7c478f

File tree

7 files changed

+283
-17
lines changed

7 files changed

+283
-17
lines changed

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,13 @@ To configure the command a option function will be passed which receives the com
3333
Default option functions:
3434

3535
- `cmd.WithStandardStreams`
36+
- `cmd.WithCustomStdout(...io.Writers)`
37+
- `cmd.WithCustomStderr(...io.Writers)`
3638
- `cmd.WithTimeout(time.Duration)`
3739
- `cmd.WithoutTimeout`
3840
- `cmd.WithWorkingDir(string)`
41+
- `cmd.WithEnvironmentVariables(cmd.EnvVars)`
42+
- `cmd.WithInheritedEnvironment(cmd.EnvVars)`
3943

4044
#### Example
4145

@@ -55,6 +59,23 @@ c := cmd.NewCommand("pwd", setWorkingDir)
5559
c.Execute()
5660
```
5761

62+
### Testing
63+
64+
You can catch output streams to `stdout` and `stderr` with `cmd.CaptureStandardOut`.
65+
66+
```golang
67+
// caputred is the captured output from all executed source code
68+
// fnResult contains the result of the executed function
69+
captured, fnResult := cmd.CaptureStandardOut(func() interface{} {
70+
c := NewCommand("echo hello", cmd.WithStandardStream)
71+
err := c.Execute()
72+
return err
73+
})
74+
75+
// prints "hello"
76+
fmt.Println(captured)
77+
```
78+
5879
## Development
5980

6081
### Running tests

command.go

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010
"time"
1111
)
1212

13-
//Command represents a single command which can be executed
13+
// Command represents a single command which can be executed
1414
type Command struct {
1515
Command string
1616
Env []string
@@ -22,10 +22,20 @@ type Command struct {
2222
executed bool
2323
exitCode int
2424
// stderr and stdout retrieve the output after the command was executed
25-
stderr bytes.Buffer
26-
stdout bytes.Buffer
25+
stderr bytes.Buffer
26+
stdout bytes.Buffer
27+
combined bytes.Buffer
2728
}
2829

30+
// EnvVars represents a map where the key is the name of the env variable
31+
// and the value is the value of the variable
32+
//
33+
// Example:
34+
//
35+
// env := map[string]string{"ENV": "VALUE"}
36+
//
37+
type EnvVars map[string]string
38+
2939
// NewCommand creates a new command
3040
// You can add option with variadic option argument
3141
// Default timeout is set to 30 minutes
@@ -68,8 +78,28 @@ func NewCommand(cmd string, options ...func(*Command)) *Command {
6878
// c.Execute()
6979
//
7080
func WithStandardStreams(c *Command) {
71-
c.StdoutWriter = os.Stdout
72-
c.StderrWriter = os.Stderr
81+
c.StdoutWriter = NewMultiplexedWriter(os.Stdout, &c.stdout, &c.combined)
82+
c.StderrWriter = NewMultiplexedWriter(os.Stderr, &c.stdout, &c.combined)
83+
}
84+
85+
// WithCustomStdout allows to add custom writers to stdout
86+
func WithCustomStdout(writers ...io.Writer) func(c *Command) {
87+
return func(c *Command) {
88+
writers = append(writers, &c.stdout, &c.combined)
89+
c.StdoutWriter = NewMultiplexedWriter(writers...)
90+
91+
c.StderrWriter = NewMultiplexedWriter(&c.stderr, &c.combined)
92+
}
93+
}
94+
95+
// WithCustomStderr allows to add custom writers to stderr
96+
func WithCustomStderr(writers ...io.Writer) func(c *Command) {
97+
return func(c *Command) {
98+
writers = append(writers, &c.stderr, &c.combined)
99+
c.StderrWriter = NewMultiplexedWriter(writers...)
100+
101+
c.StdoutWriter = NewMultiplexedWriter(&c.stdout, &c.combined)
102+
}
73103
}
74104

75105
// WithTimeout sets the timeout of the command
@@ -95,25 +125,52 @@ func WithWorkingDir(dir string) func(c *Command) {
95125
}
96126
}
97127

128+
// WithInheritedEnvironment uses the env from the current process and
129+
// allow to add more variables.
130+
func WithInheritedEnvironment(env EnvVars) func(c *Command) {
131+
return func(c *Command) {
132+
c.Env = os.Environ()
133+
134+
// Set custom variables
135+
fn := WithEnvironmentVariables(env)
136+
fn(c)
137+
}
138+
}
139+
140+
// WithEnvironmentVariables sets environment variables for the executed command
141+
func WithEnvironmentVariables(env EnvVars) func(c *Command) {
142+
return func(c *Command) {
143+
for key, value := range env {
144+
c.AddEnv(key, value)
145+
}
146+
}
147+
}
148+
98149
// AddEnv adds an environment variable to the command
99150
// If a variable gets passed like ${VAR_NAME} the env variable will be read out by the current shell
100151
func (c *Command) AddEnv(key string, value string) {
101152
value = os.ExpandEnv(value)
102153
c.Env = append(c.Env, fmt.Sprintf("%s=%s", key, value))
103154
}
104155

105-
//Stdout returns the output to stdout
156+
// Stdout returns the output to stdout
106157
func (c *Command) Stdout() string {
107158
c.isExecuted("Stdout")
108159
return c.stdout.String()
109160
}
110161

111-
//Stderr returns the output to stderr
162+
// Stderr returns the output to stderr
112163
func (c *Command) Stderr() string {
113164
c.isExecuted("Stderr")
114165
return c.stderr.String()
115166
}
116167

168+
// Combined returns the combined output of stderr and stdout according to their timeline
169+
func (c *Command) Combined() string {
170+
c.isExecuted("Combined")
171+
return c.combined.String()
172+
}
173+
117174
//ExitCode returns the exit code of the command
118175
func (c *Command) ExitCode() int {
119176
c.isExecuted("ExitCode")

command_linux_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"bytes"
45
"github.com/stretchr/testify/assert"
56
"io/ioutil"
67
"os"
@@ -81,3 +82,59 @@ func TestCommand_WithInvalidDir(t *testing.T) {
8182
assert.NotNil(t, err)
8283
assert.Equal(t, "chdir /invalid: no such file or directory", err.Error())
8384
}
85+
86+
func TestWithInheritedEnvironment(t *testing.T) {
87+
os.Setenv("FROM_OS", "is on os")
88+
os.Setenv("OVERWRITE", "is on os but should be overwritten")
89+
defer func() {
90+
os.Unsetenv("FROM_OS")
91+
os.Unsetenv("OVERWRITE")
92+
}()
93+
94+
c := NewCommand(
95+
"echo $FROM_OS $OVERWRITE",
96+
WithInheritedEnvironment(map[string]string{"OVERWRITE": "overwritten"}))
97+
c.Execute()
98+
99+
assertEqualWithLineBreak(t, "is on os overwritten", c.Stdout())
100+
}
101+
102+
func TestWithCustomStderr(t *testing.T) {
103+
writer := bytes.Buffer{}
104+
c := NewCommand(">&2 echo stderr; sleep 0.01; echo stdout;", WithCustomStderr(&writer))
105+
c.Execute()
106+
107+
assertEqualWithLineBreak(t, "stderr", writer.String())
108+
assertEqualWithLineBreak(t, "stdout", c.Stdout())
109+
assertEqualWithLineBreak(t, "stderr", c.Stderr())
110+
assertEqualWithLineBreak(t, "stderr\nstdout", c.Combined())
111+
}
112+
113+
func TestWithCustomStdout(t *testing.T) {
114+
writer := bytes.Buffer{}
115+
c := NewCommand(">&2 echo stderr; sleep 0.01; echo stdout;", WithCustomStdout(&writer))
116+
c.Execute()
117+
118+
assertEqualWithLineBreak(t, "stdout", writer.String())
119+
assertEqualWithLineBreak(t, "stdout", c.Stdout())
120+
assertEqualWithLineBreak(t, "stderr", c.Stderr())
121+
assertEqualWithLineBreak(t, "stderr\nstdout", c.Combined())
122+
}
123+
124+
func TestWithStandardStreams(t *testing.T) {
125+
out, err := CaptureStandardOutput(func() interface{} {
126+
c := NewCommand(">&2 echo stderr; sleep 0.01; echo stdout;", WithStandardStreams)
127+
err := c.Execute()
128+
return err
129+
})
130+
131+
assertEqualWithLineBreak(t, "stderr\nstdout", out)
132+
assert.Nil(t, err)
133+
}
134+
135+
func TestWithEnvironmentVariables(t *testing.T) {
136+
c := NewCommand("echo $env", WithEnvironmentVariables(map[string]string{"env": "value"}))
137+
c.Execute()
138+
139+
assertEqualWithLineBreak(t, "value", c.Stdout())
140+
}

command_test.go

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -126,13 +126,3 @@ func TestCommand_SetOptions(t *testing.T) {
126126
assert.Equal(t, time.Duration(1000000000), c.Timeout)
127127
assertEqualWithLineBreak(t, "test", writer.String())
128128
}
129-
130-
func assertEqualWithLineBreak(t *testing.T, expected string, actual string) {
131-
if runtime.GOOS == "windows" {
132-
expected = expected + "\r\n"
133-
} else {
134-
expected = expected + "\n"
135-
}
136-
137-
assert.Equal(t, expected, actual)
138-
}

multiplexed_writer.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"io"
6+
)
7+
8+
// NewMultiplexedWriter returns a new multiplexer
9+
func NewMultiplexedWriter(outputs ...io.Writer) *MultiplexedWriter {
10+
return &MultiplexedWriter{Outputs: outputs}
11+
}
12+
13+
// MultiplexedWriter writes to multiple writers at once
14+
type MultiplexedWriter struct {
15+
Outputs []io.Writer
16+
}
17+
18+
// Write writes the given bytes. If one write fails it returns the error
19+
// and bytes of the failed write operation
20+
func (w MultiplexedWriter) Write(p []byte) (n int, err error) {
21+
for _, o := range w.Outputs {
22+
n, err = o.Write(p)
23+
if err != nil {
24+
return 0, fmt.Errorf("Error in writer: %s", err.Error())
25+
}
26+
}
27+
28+
return n, nil
29+
}

multiplexed_writer_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"github.com/stretchr/testify/assert"
7+
"os"
8+
"testing"
9+
)
10+
11+
func TestMultiplexedWriter(t *testing.T) {
12+
writer01 := bytes.Buffer{}
13+
writer02 := bytes.Buffer{}
14+
// Test another io.Writer interface type
15+
r, w, _ := os.Pipe()
16+
17+
writer := NewMultiplexedWriter(&writer01, &writer02, w)
18+
n, err := writer.Write([]byte(`test`))
19+
20+
assert.Nil(t, err)
21+
assert.Equal(t, 4, n)
22+
assert.Equal(t, "test", writer01.String())
23+
assert.Equal(t, "test", writer02.String())
24+
25+
data := make([]byte, 4)
26+
n, err = r.Read(data)
27+
assert.Nil(t, err)
28+
assert.Equal(t, 4, n)
29+
assert.Equal(t, "test", string(data))
30+
}
31+
32+
func TestMultiplexedWriter_SingleWirter(t *testing.T) {
33+
writer01 := bytes.Buffer{}
34+
35+
writer := NewMultiplexedWriter(&writer01)
36+
37+
n, _ := writer.Write([]byte(`another`))
38+
39+
assert.Equal(t, 7, n)
40+
assert.Equal(t, "another", writer01.String())
41+
}
42+
43+
func TestMultiplexedWriter_Fail(t *testing.T) {
44+
writer := NewMultiplexedWriter(InvalidWriter{})
45+
46+
n, err := writer.Write([]byte(`another`))
47+
48+
assert.Equal(t, 0, n)
49+
assert.Equal(t, "Error in writer: failed", err.Error())
50+
}
51+
52+
type InvalidWriter struct {
53+
}
54+
55+
func (w InvalidWriter) Write(p []byte) (n int, err error) {
56+
return 0, fmt.Errorf("failed")
57+
}

testing_utils.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"github.com/stretchr/testify/assert"
6+
"io"
7+
"log"
8+
"os"
9+
"runtime"
10+
"sync"
11+
"testing"
12+
)
13+
14+
// CaptureStandardOutput allows to capture the output which will be written
15+
// to os.Stdout and os.Stderr.
16+
// It returns the captured output and the return value of the called function
17+
func CaptureStandardOutput(f func() interface{}) (string, interface{}) {
18+
reader, writer, err := os.Pipe()
19+
if err != nil {
20+
panic(err)
21+
}
22+
stdout := os.Stdout
23+
stderr := os.Stderr
24+
defer func() {
25+
os.Stdout = stdout
26+
os.Stderr = stderr
27+
log.SetOutput(os.Stderr)
28+
}()
29+
os.Stdout = writer
30+
os.Stderr = writer
31+
log.SetOutput(writer)
32+
out := make(chan string)
33+
wg := new(sync.WaitGroup)
34+
wg.Add(1)
35+
go func() {
36+
var buf bytes.Buffer
37+
wg.Done()
38+
io.Copy(&buf, reader)
39+
out <- buf.String()
40+
}()
41+
wg.Wait()
42+
result := f()
43+
writer.Close()
44+
return <-out, result
45+
}
46+
47+
func assertEqualWithLineBreak(t *testing.T, expected string, actual string) {
48+
if runtime.GOOS == "windows" {
49+
expected = expected + "\r\n"
50+
} else {
51+
expected = expected + "\n"
52+
}
53+
54+
assert.Equal(t, expected, actual)
55+
}

0 commit comments

Comments
 (0)