Files
ReleaseKit/internal/process/process_test.go

330 lines
9.3 KiB
Go

package process
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestRunPassesArgumentsDirectoryAndEnvironment(t *testing.T) {
workdir := filepath.Join(t.TempDir(), "directory with spaces")
if err := os.Mkdir(workdir, 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("RELEASEKIT_PROCESS_OVERLAY", "parent")
t.Setenv("RELEASEKIT_PROCESS_INHERITED", "inherited")
var stdout, stderr bytes.Buffer
err := Run(context.Background(), Invocation{
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "argument with spaces", "success"},
Dir: workdir,
Env: map[string]string{
"RELEASEKIT_PROCESS_HELPER": "1",
"RELEASEKIT_PROCESS_OVERLAY": "child",
},
Stdout: &stdout,
Stderr: &stderr,
})
if err != nil {
t.Fatal(err)
}
want := fmt.Sprintf("%s|argument with spaces|child|inherited", workdir)
if stdout.String() != want {
t.Fatalf("stdout = %q, want %q", stdout.String(), want)
}
if stderr.String() != "child stderr\n" {
t.Fatalf("stderr = %q, want %q", stderr.String(), "child stderr\n")
}
if got := os.Getenv("RELEASEKIT_PROCESS_OVERLAY"); got != "parent" {
t.Fatalf("parent environment changed: got %q, want %q", got, "parent")
}
}
func TestRunPreservesExitError(t *testing.T) {
err := Run(context.Background(), Invocation{
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "exit"},
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
})
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 7 {
t.Fatalf("Run() error = %v, want exit code 7", err)
}
}
func TestRunCanRemoveInheritedEnvironment(t *testing.T) {
t.Setenv("RELEASEKIT_PROCESS_INHERITED", "inherited")
var stdout bytes.Buffer
err := Run(context.Background(), Invocation{
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "argument", "success"},
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
UnsetEnv: []string{"RELEASEKIT_PROCESS_INHERITED"},
Stdout: &stdout,
})
if err != nil {
t.Fatal(err)
}
want := fmt.Sprintf("%s|argument||", mustGetwd(t))
if stdout.String() != want {
t.Fatalf("stdout = %q, want %q", stdout.String(), want)
}
}
func TestRunReportsMissingExecutable(t *testing.T) {
err := Run(context.Background(), Invocation{Args: []string{"releasekit-command-that-does-not-exist"}})
if err == nil || !strings.Contains(err.Error(), "releasekit-command-that-does-not-exist") {
t.Fatalf("Run() error = %v, want missing executable diagnostic", err)
}
}
func TestRunReturnsCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
started := make(chan struct{}, 1)
invocation := Invocation{
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "wait"},
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
Stdout: writerFunc(func([]byte) (int, error) {
select {
case started <- struct{}{}:
default:
}
return len("started\n"), nil
}),
}
result := make(chan error, 1)
go func() { result <- Run(ctx, invocation) }()
select {
case <-started:
cancel()
case <-time.After(5 * time.Second):
t.Fatal("child did not start")
}
select {
case err := <-result:
if !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want context cancellation", err)
}
case <-time.After(5 * time.Second):
t.Fatal("Run() did not return after cancellation")
}
}
func TestStartPipedExchangesBeforeInputEOFAndPreservesSettings(t *testing.T) {
workdir := filepath.Join(t.TempDir(), "piped directory")
if err := os.Mkdir(workdir, 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("RELEASEKIT_PROCESS_OVERLAY", "parent")
t.Setenv("RELEASEKIT_PROCESS_INHERITED", "inherited")
var stderr bytes.Buffer
child, err := StartPiped(context.Background(), Invocation{
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "argument with spaces", "exchange"},
Dir: workdir,
Env: map[string]string{
"RELEASEKIT_PROCESS_HELPER": "1",
"RELEASEKIT_PROCESS_OVERLAY": "child",
},
Stderr: &stderr,
})
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(child.Stdin(), "request\n"); err != nil {
t.Fatal(err)
}
response, err := bufio.NewReader(child.Stdout()).ReadString('\n')
if err != nil {
t.Fatalf("read response before closing input: %v", err)
}
want := fmt.Sprintf("request|%s|argument with spaces|child|inherited\n", workdir)
if response != want {
t.Fatalf("piped response = %q, want %q", response, want)
}
if err := child.Stdin().Close(); err != nil {
t.Fatal(err)
}
if _, err := io.Copy(io.Discard, child.Stdout()); err != nil {
t.Fatal(err)
}
if err := child.Wait(); err != nil {
t.Fatal(err)
}
if got := stderr.String(); got != "child stderr\n" {
t.Fatalf("child stderr = %q, want %q", got, "child stderr\n")
}
if got := os.Getenv("RELEASEKIT_PROCESS_OVERLAY"); got != "parent" {
t.Fatalf("parent environment changed: got %q, want %q", got, "parent")
}
}
func TestStartPipedCancellationAfterReadinessReapsChild(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
child, err := StartPiped(ctx, Invocation{
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "wait"},
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
})
if err != nil {
cancel()
t.Fatal(err)
}
line, err := bufio.NewReader(child.Stdout()).ReadString('\n')
if err != nil || line != "started\n" {
cancel()
_ = child.Stdin().Close()
_ = child.Stdout().Close()
_ = child.Wait()
t.Fatalf("readiness = %q, %v; want started signal", line, err)
}
cancel()
if err := child.Stdin().Close(); err != nil {
t.Fatal(err)
}
if err := child.Stdout().Close(); err != nil {
t.Fatal(err)
}
if err := child.Wait(); !errors.Is(err, context.Canceled) {
t.Fatalf("Wait() error = %v, want context cancellation", err)
}
}
func TestStartPipedCancelDoesNotCancelParentContext(t *testing.T) {
ctx := context.Background()
child, err := StartPiped(ctx, Invocation{
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "wait"},
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
})
if err != nil {
t.Fatal(err)
}
line, err := bufio.NewReader(child.Stdout()).ReadString('\n')
if err != nil || line != "started\n" {
child.Cancel()
_ = child.Stdin().Close()
_ = child.Stdout().Close()
_ = child.Wait()
t.Fatalf("readiness = %q, %v; want started signal", line, err)
}
child.Cancel()
if err := child.Stdin().Close(); err != nil {
t.Fatal(err)
}
if err := child.Stdout().Close(); err != nil {
t.Fatal(err)
}
err = child.Wait()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("Wait() error = %v, want interrupted child exit", err)
}
if ctx.Err() != nil {
t.Fatalf("child cancellation changed parent context: %v", ctx.Err())
}
}
func TestStartPipedReportsStartFailure(t *testing.T) {
child, err := StartPiped(context.Background(), Invocation{Args: []string{"releasekit-command-that-does-not-exist"}})
if err == nil || child != nil || !strings.Contains(err.Error(), "releasekit-command-that-does-not-exist") {
t.Fatalf("StartPiped() = %#v, %v; want a missing executable error", child, err)
}
}
func TestStartPipedPreservesNonzeroExit(t *testing.T) {
child, err := StartPiped(context.Background(), Invocation{
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "exit"},
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
})
if err != nil {
t.Fatal(err)
}
if err := child.Stdin().Close(); err != nil {
t.Fatal(err)
}
if _, err := io.Copy(io.Discard, child.Stdout()); err != nil {
t.Fatal(err)
}
err = child.Wait()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 7 {
t.Fatalf("Wait() error = %v, want exit code 7", err)
}
}
func TestProcessHelper(t *testing.T) {
if os.Getenv("RELEASEKIT_PROCESS_HELPER") != "1" {
return
}
args := os.Args
if len(args) < 2 {
os.Exit(8)
}
mode := args[len(args)-1]
switch mode {
case "success":
workdir, err := os.Getwd()
if err != nil {
os.Exit(9)
}
fmt.Fprintf(os.Stdout, "%s|%s|%s|%s", workdir, args[len(args)-2], os.Getenv("RELEASEKIT_PROCESS_OVERLAY"), os.Getenv("RELEASEKIT_PROCESS_INHERITED"))
fmt.Fprintln(os.Stderr, "child stderr")
os.Exit(0)
case "exit":
os.Exit(7)
case "wait":
fmt.Fprintln(os.Stdout, "started")
for {
time.Sleep(time.Second)
}
case "exchange":
reader := bufio.NewReader(os.Stdin)
request, err := reader.ReadString('\n')
if err != nil {
os.Exit(11)
}
workdir, err := os.Getwd()
if err != nil {
os.Exit(12)
}
fmt.Fprintf(os.Stdout, "%s|%s|%s|%s|%s\n", strings.TrimSuffix(request, "\n"), workdir, args[len(args)-2], os.Getenv("RELEASEKIT_PROCESS_OVERLAY"), os.Getenv("RELEASEKIT_PROCESS_INHERITED"))
fmt.Fprintln(os.Stderr, "child stderr")
if _, err := io.Copy(io.Discard, reader); err != nil {
os.Exit(13)
}
default:
os.Exit(10)
}
}
type writerFunc func([]byte) (int, error)
func (write writerFunc) Write(p []byte) (int, error) {
return write(p)
}
func helperExecutable(t *testing.T) string {
t.Helper()
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
return executable
}
func mustGetwd(t *testing.T) string {
t.Helper()
workdir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
return workdir
}