// Copyright IBM Corp. 2020, 2025 // SPDX-License-Identifier: BUSL-1.1 package e2e import ( "bufio" "bytes" "context" "errors" "fmt" "io" "os" "os/exec" "testing" "time" ) // CommandResult captures the output from running an external command type CommandResult struct { Stdout []byte Stderr []byte ExitCode int Err error Duration time.Duration } // Option is a func that sets optional attributes for a call. This does not need // to be used directly, but instead option arguments are built from the // functions in this package. WithX options set a value to that given in the // argument; DefaultX options indicate that the value should be set to its // default. When an API call is made options are processed in the order they // appear in the function call, so for a given argument X, a succession of WithX // or DefaultX calls will result in the last call taking effect. type Option func(*options) type options struct { withArgs []string withEnv map[string]string } func getOpts(opt ...Option) options { opts := options{} for _, o := range opt { if o != nil { o(&opts) } } return opts } const ( EnvToCheckSkip = "E2E_TESTS" EnvToCheckSlowSkip = "E2E_SLOW_TESTS" ) // RunCommand executes external commands on the system. Returns the results // of running the provided command. // // RunCommand(context.Background(), "ls") // RunCommand(context.Background(), "ls", WithArgs("-al", "/path")) // // CommandResult is always valid even if there is an error. func RunCommand(ctx context.Context, command string, opt ...Option) *CommandResult { var cmd *exec.Cmd var outbuf, errbuf bytes.Buffer opts := getOpts(opt...) if opts.withArgs == nil { cmd = exec.CommandContext(ctx, command) } else { cmd = exec.CommandContext(ctx, command, opts.withArgs...) } if opts.withEnv != nil { cmd.Env = os.Environ() for k, v := range opts.withEnv { cmd.Env = append(cmd.Env, k+"="+v) } } cmd.Stdout = &outbuf cmd.Stderr = &errbuf startTime := time.Now() err := cmd.Run() endTime := time.Now() duration := endTime.Sub(startTime) var ee *exec.ExitError var exitCode int if errors.As(err, &ee) { exitCode = ee.ExitCode() } return &CommandResult{ Stdout: outbuf.Bytes(), Stderr: errbuf.Bytes(), ExitCode: exitCode, Err: err, Duration: duration, } } // RunCommandWithPipe runs a command and captures the first line of output from its stdout pipe. // This implementation works on cross platforms (Windows, Unix, Linux, macOS) - unlike the pty-based version above (Unix, Linux, macOS). // StdoutPipe only captures stdout, as opposed to pty which is an interactive pseudo-terminal func RunCommandWithPipe(t testing.TB, ctx context.Context, command string, opt ...Option) (string, error) { ctxCancel, cancel := context.WithCancel(ctx) // channels to capture output and errors from goroutine outputChan := make(chan string, 1) errorChan := make(chan error, 1) // Process options opts := getOpts(opt...) // Build command with args var cmd *exec.Cmd if opts.withArgs == nil { cmd = exec.CommandContext(ctxCancel, command) } else { cmd = exec.CommandContext(ctxCancel, command, opts.withArgs...) } // Apply environment variables if opts.withEnv != nil { cmd.Env = os.Environ() for k, v := range opts.withEnv { cmd.Env = append(cmd.Env, k+"="+v) } } // Run goroutine in background go func() { // Capture stdout via pipe stdoutPipe, err := cmd.StdoutPipe() if err != nil { errorChan <- fmt.Errorf("failed to create standard out pipe: %w", err) return } // Start the command (don't wait for it to finish) err = cmd.Start() if err != nil { errorChan <- fmt.Errorf("failed to start command: %w", err) return } // Read first line of output and send to channel scanner := bufio.NewScanner(stdoutPipe) if scanner.Scan() { outputChan <- scanner.Text() } else { if err := scanner.Err(); err != nil { errorChan <- fmt.Errorf("failed read output from pipe: %w", err) } else { errorChan <- errors.New("no output from command") } } // Command continues running after this as proxy connection is intended to stay open // We only need the first line of output // Continuously drains the stdout pipe in the background to prevent the command from blocking go func() { _, _ = io.Copy(io.Discard, stdoutPipe) }() }() // Cleanup kills the process t.Cleanup(func() { cancel() }) // Return result of goroutine select { case output := <-outputChan: return output, nil case err := <-errorChan: return "", err case <-ctxCancel.Done(): return "", ctxCancel.Err() } } // WithArgs is an option to RunCommand that allows the user to specify arguments // for the provided command. This option can be used multiple times in one command. // // RunCommand(context.Background(), "ls", WithArgs("-al")) func WithArgs(args ...string) Option { return func(o *options) { if o.withArgs == nil { o.withArgs = args } else { o.withArgs = append(o.withArgs, args...) } } } // WithEnv is an option to RunCommand that allows the user to specify environment variables // to be set when running the command. This option can be used multiple times in one command. // // RunCommand(context.Background(), "ls", WithEnv("NAME", "VALUE"), WithEnv("NAME", "VALUE")) func WithEnv(name string, value string) Option { return func(o *options) { if o.withEnv == nil { o.withEnv = map[string]string{name: value} } else { o.withEnv[name] = value } } } // MaybeSkipTest is a check used at the start of the test to determine if the test should run func MaybeSkipTest(t testing.TB) { if _, ok := os.LookupEnv(EnvToCheckSkip); !ok { t.Skipf( "Skipping test because environment variable %q is not set. This is needed for e2e tests.", EnvToCheckSkip, ) } } // MaybeSkipSlowTest is a check used at the start of the test to determine if the test should run func MaybeSkipSlowTest(t testing.TB) { MaybeSkipTest(t) if _, ok := os.LookupEnv(EnvToCheckSlowSkip); !ok { t.Skipf( "Skipping test because environment variable %q is not set. This is needed for slow e2e tests.", EnvToCheckSlowSkip, ) } }