Files
summercms/cmd/summer/runtime_test.go
Jakub Zych 9c89705747 fix(summer): give delegated commands the terminal and keep their stderr apart
- appStreams hands the app binary summer's own stdout and stderr on a
  terminal, so it asks the production question, prints styled summaries
  and animates its widgets
- off a terminal the binary's stdout goes through summer's Output and its
  stderr to summer's stderr, so --json and -q output carry no log lines
- newAppCmd (unexported) takes the two writers; docs say the streams stay
  apart (CR-03)
2026-10-22 02:19:01 +02:00

416 lines
13 KiB
Go

package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"syscall"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/bonfire"
)
// captureAppRunner replaces appRunner for the test and returns a pointer to
// the argv of the last call.
func captureAppRunner(t *testing.T) *[]string {
t.Helper()
got := new([]string)
prev := appRunner
appRunner = func(ctx context.Context, argv []string, out bonfire.Output) error {
*got = append([]string(nil), argv...)
return nil
}
t.Cleanup(func() { appRunner = prev })
return got
}
func TestDelegatedServePassesFlags(t *testing.T) {
for _, tc := range []struct {
args []string
want []string
}{
{[]string{"serve", "--addr", ":8123"}, []string{"serve", "--addr", ":8123"}},
{[]string{"serve", "--help"}, []string{"serve", "--help"}},
{[]string{"serve", "-h"}, []string{"serve", "-h"}},
{[]string{"serve", "--addr=:8123"}, []string{"serve", "--addr=:8123"}},
{[]string{"serve", "--", "--addr", ":8123"}, []string{"serve", "--addr", ":8123"}},
} {
got := captureAppRunner(t)
if out, err := runSummer(t, tc.args...); err != nil {
t.Fatalf("%q: %v\n%s", tc.args, err, out)
}
if !slices.Equal(*got, tc.want) {
t.Fatalf("%q: argv = %q, want %q", tc.args, *got, tc.want)
}
}
}
func TestDelegateCommandsPassArgs(t *testing.T) {
for _, args := range [][]string{
{"migrate:rollback", "--plugin", "acme.blog"},
{"queue:work", "--queue", "a", "--queue", "b"},
{"schedule:run", "--once"},
{"queue:clear", "emails"},
{"migrate"},
{"migrate:status", "--help"},
{"route:list"},
{"key:generate"},
{"admin:create", "--email", "you@example.com", "--superuser"},
{"admin:reset-password", "admin@example.com"},
} {
got := captureAppRunner(t)
if out, err := runSummer(t, args...); err != nil {
t.Fatalf("%q: %v\n%s", args, err, out)
}
if !slices.Equal(*got, args) {
t.Fatalf("%q: argv = %q, want %q", args, *got, args)
}
}
}
func TestDelegateCommandsHelp(t *testing.T) {
hints := map[string][]string{
"migrate:rollback": {"--plugin"},
"queue:work": {"--queue"},
"schedule:run": {"--once"},
"serve": {"--addr"},
"admin:create": {"--email", "--superuser"},
"admin:token:create": {"--name", "--scope", "--expires"},
}
cmds := delegateCommands()
if len(cmds) != 15 {
t.Fatalf("delegateCommands() has %d commands, want 15", len(cmds))
}
got := captureAppRunner(t)
for _, c := range cmds {
out, err := runSummer(t, "help", c.Name)
if err != nil {
t.Fatalf("help %s: %v", c.Name, err)
}
for _, want := range append([]string{"passed to the app binary"}, hints[c.Name]...) {
if !strings.Contains(out, want) {
t.Fatalf("help %s missing %q:\n%s", c.Name, want, out)
}
}
}
if *got != nil {
t.Fatalf("help ran the app binary with %q", *got)
}
out, err := runSummer(t, "--help")
if err != nil {
t.Fatal(err)
}
for _, c := range cmds {
found := false
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if len(fields) > 0 && fields[0] == c.Name && strings.Contains(line, "(flags pass to the app binary)") {
found = true
break
}
}
if !found {
t.Fatalf("summer --help does not mark %s:\n%s", c.Name, out)
}
}
}
// TestSignalHelperProcess is not a real test: the signal tests re-execute the
// test binary with SUMMER_SIGNAL_HELPER=1 to get a child that shuts down
// gracefully on SIGINT or SIGTERM, like the app binary's serve.
func TestSignalHelperProcess(t *testing.T) {
if os.Getenv("SUMMER_SIGNAL_HELPER") != "1" {
return
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
fmt.Println("ready")
select {
case <-ctx.Done():
fmt.Println("graceful shutdown")
os.Exit(0)
case <-time.After(20 * time.Second):
os.Exit(3)
}
}
// syncBuffer is a bytes.Buffer safe for the child's writes and the test's
// reads at the same time.
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
func helperArgv() []string {
return []string{"-test.run=^TestSignalHelperProcess$"}
}
func helperEnv(cmd *exec.Cmd) {
cmd.Env = append(os.Environ(), "SUMMER_SIGNAL_HELPER=1")
}
func waitForOutput(t *testing.T, out *syncBuffer, want string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !strings.Contains(out.String(), want) {
if time.Now().After(deadline) {
t.Fatalf("child never printed %q; output:\n%s", want, out.String())
}
time.Sleep(10 * time.Millisecond)
}
}
func TestRunChildForwardsSignals(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("signal forwarding is POSIX-only")
}
for _, sig := range []os.Signal{os.Interrupt, syscall.SIGTERM} {
t.Run(sig.String(), func(t *testing.T) {
var out syncBuffer
cmd := newAppCmd(context.Background(), os.Args[0], ".", helperArgv(), &out, &out)
helperEnv(cmd)
sigs := make(chan os.Signal, 1)
done := make(chan error, 1)
go func() { done <- runChild(cmd, sigs) }()
waitForOutput(t, &out, "ready")
sigs <- sig
select {
case err := <-done:
if err != nil {
t.Fatalf("runChild: %v\n%s", err, out.String())
}
case <-time.After(10 * time.Second):
t.Fatal("runChild did not return after the signal")
}
if !strings.Contains(out.String(), "graceful shutdown") {
t.Fatalf("child did not shut down gracefully:\n%s", out.String())
}
})
}
}
func TestNewAppCmdCancelSendsSIGTERM(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("SIGTERM is POSIX-only")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var out syncBuffer
cmd := newAppCmd(ctx, os.Args[0], ".", helperArgv(), &out, &out)
helperEnv(cmd)
done := make(chan error, 1)
go func() { done <- runChild(cmd, nil) }()
waitForOutput(t, &out, "ready")
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("err = %v, want context.Canceled\n%s", err, out.String())
}
case <-time.After(10 * time.Second):
t.Fatal("child did not exit after cancel")
}
if !strings.Contains(out.String(), "graceful shutdown") {
t.Fatalf("child was not stopped with SIGTERM:\n%s", out.String())
}
}
// TestDelegateExamples: every delegate has two examples rendered after
// summer in its help, and its long help says that flags and the global
// options pass to the app binary; global options before or after the
// command word reach the app binary.
func TestDelegateExamples(t *testing.T) {
got := captureAppRunner(t)
for _, c := range delegateCommands() {
if len(c.Examples) != 2 {
t.Errorf("%s has %d examples, want 2", c.Name, len(c.Examples))
}
if !strings.Contains(c.Long, "global options (-v, -q, --ansi, --no-ansi, -n, --env)") || !strings.Contains(c.Long, "passed to the app binary unchanged") {
t.Errorf("%s: long help does not say that global options pass through:\n%s", c.Name, c.Long)
}
out, err := runSummer(t, "help", c.Name)
if err != nil {
t.Fatalf("help %s: %v", c.Name, err)
}
for _, e := range c.Examples {
if !strings.HasPrefix(e.Command, c.Name) || e.Description == "" {
t.Errorf("%s: example %+v does not start with the command or lacks a description", c.Name, e)
}
if !strings.Contains(out, " summer "+e.Command+"\n") || !strings.Contains(out, "# "+e.Description) {
t.Errorf("help %s lacks the example %q:\n%s", c.Name, e.Command, out)
}
}
}
if *got != nil {
t.Fatalf("help ran the app binary with %q", *got)
}
if problems := bonfire.CheckDocs(delegateCommands()); len(problems) != 0 {
t.Errorf("delegates lack documentation: %v", problems)
}
for _, tc := range []struct {
args []string
want []string
}{
{[]string{"-v", "migrate", "--pretend"}, []string{"migrate", "-v", "--pretend"}},
{[]string{"migrate", "-qn"}, []string{"migrate", "-qn"}},
{[]string{"--env", "staging", "queue:work"}, []string{"queue:work", "--env", "staging"}},
{[]string{"--no-ansi", "serve", "--addr", ":8123"}, []string{"serve", "--no-ansi", "--addr", ":8123"}},
} {
got := captureAppRunner(t)
if out, err := runSummer(t, tc.args...); err != nil {
t.Fatalf("%q: %v\n%s", tc.args, err, out)
}
if !slices.Equal(*got, tc.want) {
t.Errorf("%q: argv = %q, want %q", tc.args, *got, tc.want)
}
}
}
// TestExitHelperProcess is not a real test: TestDelegateExitCodePassthrough
// re-executes the test binary with SUMMER_EXIT_HELPER set to get a child
// that prints an error on stderr and exits 2, or that is killed by SIGKILL.
func TestExitHelperProcess(t *testing.T) {
switch os.Getenv("SUMMER_EXIT_HELPER") {
case "exit2":
fmt.Fprintln(os.Stderr, "ERROR the app binary failed")
os.Exit(2)
case "sigkill":
p, _ := os.FindProcess(os.Getpid())
_ = p.Signal(syscall.SIGKILL)
time.Sleep(10 * time.Second)
os.Exit(3)
}
}
// exitHelperRunner replaces appRunner with one that runs the exit helper
// as the app binary.
func exitHelperRunner(t *testing.T, mode string) {
t.Helper()
prev := appRunner
appRunner = func(ctx context.Context, argv []string, out bonfire.Output) error {
stdout, stderr := appStreams(out, bonfire.IsTerminal(out))
cmd := newAppCmd(ctx, os.Args[0], ".", []string{"-test.run=^TestExitHelperProcess$"}, stdout, stderr)
cmd.Env = append(os.Environ(), "SUMMER_EXIT_HELPER="+mode)
return runAppCmd(cmd, argv)
}
t.Cleanup(func() { appRunner = prev })
}
// TestDelegateExitCodePassthrough (CR-02): summer exits with the app
// binary's exit code and prints no error block of its own; a binary killed
// by a signal gets summer's error block and exit 1.
func TestDelegateExitCodePassthrough(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("SIGKILL is POSIX-only")
}
run := func(t *testing.T, mode string) (string, string, int) {
t.Helper()
exitHelperRunner(t, mode)
var out, errW syncBuffer
root, err := bonfire.NewRootIO("summer", toolCommands(), strings.NewReader(""), &out, &errW)
if err != nil {
t.Fatal(err)
}
code := bonfire.Execute(context.Background(), root, []string{"migrate", "--bogus"})
return out.String(), errW.String(), code
}
t.Run("exit code", func(t *testing.T) {
out, errOut, code := run(t, "exit2")
if code != 2 {
t.Errorf("exit %d, want the binary's 2", code)
}
for _, s := range []string{out, errOut} {
if strings.Contains(s, " ERROR ") || strings.Contains(s, "summer: migrate") {
t.Errorf("summer printed an error block of its own:\nstdout %q\nstderr %q", out, errOut)
break
}
}
})
t.Run("killed by a signal", func(t *testing.T) {
out, errOut, code := run(t, "sigkill")
if code != bonfire.ExitFailure {
t.Errorf("exit %d, want 1", code)
}
if n := strings.Count(errOut, " ERROR "); n != 1 || !strings.Contains(errOut, "summer: migrate --bogus: signal: killed") {
t.Errorf("want one error block naming the signal, got %d:\nstdout %q\nstderr %q", n, out, errOut)
}
})
}
// TestDelegateStreams (CR-03): off a terminal the app binary's stdout goes
// through summer's Output and its stderr to summer's stderr, never into
// summer's stdout.
func TestDelegateStreams(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("the fake app binary is a shell script")
}
app := t.TempDir()
if err := os.WriteFile(filepath.Join(app, "summer.yaml"), []byte("module: example.com/acme\nbinary: acme\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(app, "bin"), 0o755); err != nil {
t.Fatal(err)
}
script := "#!/bin/sh\necho data-line\necho log-line >&2\n"
if err := os.WriteFile(filepath.Join(app, "bin", "acme"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Chdir(app)
out, err := runSummer(t, "route:list", "--json")
if err != nil {
t.Fatalf("route:list: %v\n%s", err, out)
}
if !strings.Contains(out, "data-line") {
t.Errorf("the binary's stdout did not reach summer's Output: %q", out)
}
if strings.Contains(out, "log-line") {
t.Errorf("the binary's stderr landed in summer's stdout: %q", out)
}
}
// TestAppStreams (CR-03): on a terminal the app binary gets summer's own
// stdout and stderr; off a terminal its stdout goes through the Output and
// its stderr to summer's stderr.
func TestAppStreams(t *testing.T) {
var buf bytes.Buffer
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
if bonfire.IsTerminal(out) {
t.Fatal("a buffer console reports a terminal")
}
stdout, stderr := appStreams(out, true)
if stdout != os.Stdout || stderr != os.Stderr {
t.Errorf("terminal: streams %v, %v; want os.Stdout, os.Stderr", stdout, stderr)
}
stdout, stderr = appStreams(out, false)
if stdout != io.Writer(out) || stderr != os.Stderr {
t.Errorf("not a terminal: streams %v, %v; want the Output, os.Stderr", stdout, stderr)
}
}