Files
summercms/cmd/summer/runtime_test.go

239 lines
6.3 KiB
Go

package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"os/signal"
"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)
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)
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())
}
}