192 lines
6.2 KiB
Go
192 lines
6.2 KiB
Go
|
package terminal
|
||
|
|
||
|
import (
|
||
|
"fmt"
|
||
|
"io"
|
||
|
"os"
|
||
|
"strings"
|
||
|
"sync"
|
||
|
"testing"
|
||
|
)
|
||
|
|
||
|
// StreamsForTesting is a helper for test code that is aiming to test functions
|
||
|
// that interact with the input and output streams.
|
||
|
//
|
||
|
// This particular function is for the simple case of a function that only
|
||
|
// produces output: the returned input stream is connected to the system's
|
||
|
// "null device", as if a user had run Terraform with I/O redirection like
|
||
|
// </dev/null on Unix. It also configures the output as a pipe rather than
|
||
|
// as a terminal, and so can't be used to test whether code is able to adapt
|
||
|
// to different terminal widths.
|
||
|
//
|
||
|
// The return values are a Streams object ready to pass into a function under
|
||
|
// test, and a callback function for the test itself to call afterwards
|
||
|
// in order to obtain any characters that were written to the streams. Once
|
||
|
// you call the close function, the Streams object becomes invalid and must
|
||
|
// not be used anymore. Any caller of this function _must_ call close before
|
||
|
// its test concludes, even if it doesn't intend to check the output, or else
|
||
|
// it will leak resources.
|
||
|
//
|
||
|
// Since this function is for testing only, for convenience it will react to
|
||
|
// any setup errors by logging a message to the given testing.T object and
|
||
|
// then failing the test, preventing any later code from running.
|
||
|
func StreamsForTesting(t *testing.T) (streams *Streams, close func(*testing.T) *TestOutput) {
|
||
|
stdinR, err := os.Open(os.DevNull)
|
||
|
if err != nil {
|
||
|
t.Fatalf("failed to open /dev/null to represent stdin: %s", err)
|
||
|
}
|
||
|
|
||
|
// (Although we only have StreamsForTesting right now, it seems plausible
|
||
|
// that we'll want some other similar helpers for more complicated
|
||
|
// situations, such as codepaths that need to read from Stdin or
|
||
|
// tests for whether a function responds properly to terminal width.
|
||
|
// In that case, we'd probably want to factor out the core guts of this
|
||
|
// which set up the pipe *os.File values and the goroutines, but then
|
||
|
// let each caller produce its own Streams wrapping around those. For
|
||
|
// now though, it's simpler to just have this whole implementation together
|
||
|
// in one function.)
|
||
|
|
||
|
// Our idea of streams is only a very thin wrapper around OS-level file
|
||
|
// descriptors, so in order to produce a realistic implementation for
|
||
|
// the code under test while still allowing us to capture the output
|
||
|
// we'll OS-level pipes and concurrently copy anything we read from
|
||
|
// them into the output object.
|
||
|
outp := &TestOutput{}
|
||
|
var lock sync.Mutex // hold while appending to outp
|
||
|
stdoutR, stdoutW, err := os.Pipe()
|
||
|
if err != nil {
|
||
|
t.Fatalf("failed to create stdout pipe: %s", err)
|
||
|
}
|
||
|
stderrR, stderrW, err := os.Pipe()
|
||
|
if err != nil {
|
||
|
t.Fatalf("failed to create stderr pipe: %s", err)
|
||
|
}
|
||
|
var wg sync.WaitGroup // for waiting until our goroutines have exited
|
||
|
|
||
|
// We need an extra goroutine for each of the pipes so we can block
|
||
|
// on reading both of them alongside the caller hopefully writing to
|
||
|
// the write sides.
|
||
|
wg.Add(2)
|
||
|
consume := func(r *os.File, isErr bool) {
|
||
|
var buf [1024]byte
|
||
|
for {
|
||
|
n, err := r.Read(buf[:])
|
||
|
if err != nil {
|
||
|
if err != io.EOF {
|
||
|
// We aren't allowed to write to the testing.T from
|
||
|
// a different goroutine than it was created on, but
|
||
|
// encountering other errors would be weird here anyway
|
||
|
// so we'll just panic. (If we were to just ignore this
|
||
|
// and then drop out of the loop then we might deadlock
|
||
|
// anyone still trying to write to the write end.)
|
||
|
panic(fmt.Sprintf("failed to read from pipe: %s", err))
|
||
|
}
|
||
|
break
|
||
|
}
|
||
|
lock.Lock()
|
||
|
outp.parts = append(outp.parts, testOutputPart{
|
||
|
isErr: isErr,
|
||
|
bytes: append(([]byte)(nil), buf[:n]...), // copy so we can reuse the buffer
|
||
|
})
|
||
|
lock.Unlock()
|
||
|
}
|
||
|
wg.Done()
|
||
|
}
|
||
|
go consume(stdoutR, false)
|
||
|
go consume(stderrR, true)
|
||
|
|
||
|
close = func(t *testing.T) *TestOutput {
|
||
|
err := stdinR.Close()
|
||
|
if err != nil {
|
||
|
t.Errorf("failed to close stdin handle: %s", err)
|
||
|
}
|
||
|
|
||
|
// We'll close both of the writer streams now, which should in turn
|
||
|
// cause both of the "consume" goroutines above to terminate by
|
||
|
// encountering io.EOF.
|
||
|
err = stdoutW.Close()
|
||
|
if err != nil {
|
||
|
t.Errorf("failed to close stdout pipe: %s", err)
|
||
|
}
|
||
|
err = stderrW.Close()
|
||
|
if err != nil {
|
||
|
t.Errorf("failed to close stderr pipe: %s", err)
|
||
|
}
|
||
|
|
||
|
// The above error cases still allow this to complete and thus
|
||
|
// potentially allow the test to report its own result, but will
|
||
|
// ensure that the test doesn't pass while also leaking resources.
|
||
|
|
||
|
// Wait for the stream-copying goroutines to finish anything they
|
||
|
// are working on before we return, or else we might miss some
|
||
|
// late-arriving writes.
|
||
|
wg.Wait()
|
||
|
return outp
|
||
|
}
|
||
|
|
||
|
return &Streams{
|
||
|
Stdout: &OutputStream{
|
||
|
File: stdoutW,
|
||
|
},
|
||
|
Stderr: &OutputStream{
|
||
|
File: stderrW,
|
||
|
},
|
||
|
Stdin: &InputStream{
|
||
|
File: stdinR,
|
||
|
},
|
||
|
}, close
|
||
|
}
|
||
|
|
||
|
// TestOutput is a type used to return the results from the various stream
|
||
|
// testing helpers. It encapsulates any captured writes to the output and
|
||
|
// error streams, and has methods to consume that data in some different ways
|
||
|
// to allow for a few different styles of testing.
|
||
|
type TestOutput struct {
|
||
|
parts []testOutputPart
|
||
|
}
|
||
|
|
||
|
type testOutputPart struct {
|
||
|
// isErr is true if this part was written to the error stream, or false
|
||
|
// if it was written to the output stream.
|
||
|
isErr bool
|
||
|
|
||
|
// bytes are the raw bytes that were written
|
||
|
bytes []byte
|
||
|
}
|
||
|
|
||
|
// All returns the output written to both the Stdout and Stderr streams,
|
||
|
// interleaved together in the order of writing in a single string.
|
||
|
func (o TestOutput) All() string {
|
||
|
buf := &strings.Builder{}
|
||
|
for _, part := range o.parts {
|
||
|
buf.Write(part.bytes)
|
||
|
}
|
||
|
return buf.String()
|
||
|
}
|
||
|
|
||
|
// Stdout returns the output written to just the Stdout stream, ignoring
|
||
|
// anything that was written to the Stderr stream.
|
||
|
func (o TestOutput) Stdout() string {
|
||
|
buf := &strings.Builder{}
|
||
|
for _, part := range o.parts {
|
||
|
if part.isErr {
|
||
|
continue
|
||
|
}
|
||
|
buf.Write(part.bytes)
|
||
|
}
|
||
|
return buf.String()
|
||
|
}
|
||
|
|
||
|
// Stderr returns the output written to just the Stderr stream, ignoring
|
||
|
// anything that was written to the Stdout stream.
|
||
|
func (o TestOutput) Stderr() string {
|
||
|
buf := &strings.Builder{}
|
||
|
for _, part := range o.parts {
|
||
|
if !part.isErr {
|
||
|
continue
|
||
|
}
|
||
|
buf.Write(part.bytes)
|
||
|
}
|
||
|
return buf.String()
|
||
|
}
|