- 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)
416 lines
13 KiB
Go
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)
|
|
}
|
|
}
|