239 lines
6.3 KiB
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())
|
|
}
|
|
}
|