github.com/opentofu/opentofu@v1.7.1/internal/terminal/testing.go (about)

     1  // Copyright (c) The OpenTofu Authors
     2  // SPDX-License-Identifier: MPL-2.0
     3  // Copyright (c) 2023 HashiCorp, Inc.
     4  // SPDX-License-Identifier: MPL-2.0
     5  
     6  package terminal
     7  
     8  import (
     9  	"fmt"
    10  	"io"
    11  	"os"
    12  	"strings"
    13  	"sync"
    14  	"testing"
    15  )
    16  
    17  // StreamsForTesting is a helper for test code that is aiming to test functions
    18  // that interact with the input and output streams.
    19  //
    20  // This particular function is for the simple case of a function that only
    21  // produces output: the returned input stream is connected to the system's
    22  // "null device", as if a user had run OpenTofu with I/O redirection like
    23  // </dev/null on Unix. It also configures the output as a pipe rather than
    24  // as a terminal, and so can't be used to test whether code is able to adapt
    25  // to different terminal widths.
    26  //
    27  // The return values are a Streams object ready to pass into a function under
    28  // test, and a callback function for the test itself to call afterwards
    29  // in order to obtain any characters that were written to the streams. Once
    30  // you call the close function, the Streams object becomes invalid and must
    31  // not be used anymore. Any caller of this function _must_ call close before
    32  // its test concludes, even if it doesn't intend to check the output, or else
    33  // it will leak resources.
    34  //
    35  // Since this function is for testing only, for convenience it will react to
    36  // any setup errors by logging a message to the given testing.T object and
    37  // then failing the test, preventing any later code from running.
    38  func StreamsForTesting(t *testing.T) (streams *Streams, close func(*testing.T) *TestOutput) {
    39  	stdinR, err := os.Open(os.DevNull)
    40  	if err != nil {
    41  		t.Fatalf("failed to open /dev/null to represent stdin: %s", err)
    42  	}
    43  
    44  	// (Although we only have StreamsForTesting right now, it seems plausible
    45  	// that we'll want some other similar helpers for more complicated
    46  	// situations, such as codepaths that need to read from Stdin or
    47  	// tests for whether a function responds properly to terminal width.
    48  	// In that case, we'd probably want to factor out the core guts of this
    49  	// which set up the pipe *os.File values and the goroutines, but then
    50  	// let each caller produce its own Streams wrapping around those. For
    51  	// now though, it's simpler to just have this whole implementation together
    52  	// in one function.)
    53  
    54  	// Our idea of streams is only a very thin wrapper around OS-level file
    55  	// descriptors, so in order to produce a realistic implementation for
    56  	// the code under test while still allowing us to capture the output
    57  	// we'll OS-level pipes and concurrently copy anything we read from
    58  	// them into the output object.
    59  	outp := &TestOutput{}
    60  	var lock sync.Mutex // hold while appending to outp
    61  	stdoutR, stdoutW, err := os.Pipe()
    62  	if err != nil {
    63  		t.Fatalf("failed to create stdout pipe: %s", err)
    64  	}
    65  	stderrR, stderrW, err := os.Pipe()
    66  	if err != nil {
    67  		t.Fatalf("failed to create stderr pipe: %s", err)
    68  	}
    69  	var wg sync.WaitGroup // for waiting until our goroutines have exited
    70  
    71  	// We need an extra goroutine for each of the pipes so we can block
    72  	// on reading both of them alongside the caller hopefully writing to
    73  	// the write sides.
    74  	wg.Add(2)
    75  	consume := func(r *os.File, isErr bool) {
    76  		var buf [1024]byte
    77  		for {
    78  			n, err := r.Read(buf[:])
    79  			if err != nil {
    80  				if err != io.EOF {
    81  					// We aren't allowed to write to the testing.T from
    82  					// a different goroutine than it was created on, but
    83  					// encountering other errors would be weird here anyway
    84  					// so we'll just panic. (If we were to just ignore this
    85  					// and then drop out of the loop then we might deadlock
    86  					// anyone still trying to write to the write end.)
    87  					panic(fmt.Sprintf("failed to read from pipe: %s", err))
    88  				}
    89  				break
    90  			}
    91  			lock.Lock()
    92  			outp.parts = append(outp.parts, testOutputPart{
    93  				isErr: isErr,
    94  				bytes: append(([]byte)(nil), buf[:n]...), // copy so we can reuse the buffer
    95  			})
    96  			lock.Unlock()
    97  		}
    98  		wg.Done()
    99  	}
   100  	go consume(stdoutR, false)
   101  	go consume(stderrR, true)
   102  
   103  	close = func(t *testing.T) *TestOutput {
   104  		err := stdinR.Close()
   105  		if err != nil {
   106  			t.Errorf("failed to close stdin handle: %s", err)
   107  		}
   108  
   109  		// We'll close both of the writer streams now, which should in turn
   110  		// cause both of the "consume" goroutines above to terminate by
   111  		// encountering io.EOF.
   112  		err = stdoutW.Close()
   113  		if err != nil {
   114  			t.Errorf("failed to close stdout pipe: %s", err)
   115  		}
   116  		err = stderrW.Close()
   117  		if err != nil {
   118  			t.Errorf("failed to close stderr pipe: %s", err)
   119  		}
   120  
   121  		// The above error cases still allow this to complete and thus
   122  		// potentially allow the test to report its own result, but will
   123  		// ensure that the test doesn't pass while also leaking resources.
   124  
   125  		// Wait for the stream-copying goroutines to finish anything they
   126  		// are working on before we return, or else we might miss some
   127  		// late-arriving writes.
   128  		wg.Wait()
   129  		return outp
   130  	}
   131  
   132  	return &Streams{
   133  		Stdout: &OutputStream{
   134  			File: stdoutW,
   135  		},
   136  		Stderr: &OutputStream{
   137  			File: stderrW,
   138  		},
   139  		Stdin: &InputStream{
   140  			File: stdinR,
   141  		},
   142  	}, close
   143  }
   144  
   145  // TestOutput is a type used to return the results from the various stream
   146  // testing helpers. It encapsulates any captured writes to the output and
   147  // error streams, and has methods to consume that data in some different ways
   148  // to allow for a few different styles of testing.
   149  type TestOutput struct {
   150  	parts []testOutputPart
   151  }
   152  
   153  type testOutputPart struct {
   154  	// isErr is true if this part was written to the error stream, or false
   155  	// if it was written to the output stream.
   156  	isErr bool
   157  
   158  	// bytes are the raw bytes that were written
   159  	bytes []byte
   160  }
   161  
   162  // All returns the output written to both the Stdout and Stderr streams,
   163  // interleaved together in the order of writing in a single string.
   164  func (o TestOutput) All() string {
   165  	buf := &strings.Builder{}
   166  	for _, part := range o.parts {
   167  		buf.Write(part.bytes)
   168  	}
   169  	return buf.String()
   170  }
   171  
   172  // Stdout returns the output written to just the Stdout stream, ignoring
   173  // anything that was written to the Stderr stream.
   174  func (o TestOutput) Stdout() string {
   175  	buf := &strings.Builder{}
   176  	for _, part := range o.parts {
   177  		if part.isErr {
   178  			continue
   179  		}
   180  		buf.Write(part.bytes)
   181  	}
   182  	return buf.String()
   183  }
   184  
   185  // Stderr returns the output written to just the Stderr stream, ignoring
   186  // anything that was written to the Stdout stream.
   187  func (o TestOutput) Stderr() string {
   188  	buf := &strings.Builder{}
   189  	for _, part := range o.parts {
   190  		if !part.isErr {
   191  			continue
   192  		}
   193  		buf.Write(part.bytes)
   194  	}
   195  	return buf.String()
   196  }