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 }