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"}, } cmds := delegateCommands() if len(cmds) != 11 { t.Fatalf("delegateCommands() has %d commands, want 11", 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()) } }