//go:build linux package conga import ( "context" "fmt" "io" "os" "strings" "sync" "syscall" "testing" "time" "unsafe" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/compass" ) // ptyOutput collects what the program writes to a pseudo-terminal. type ptyOutput struct { mu sync.Mutex buf strings.Builder } func (p *ptyOutput) String() string { p.mu.Lock() defer p.mu.Unlock() return p.buf.String() } // openPTY opens a pseudo-terminal pair: the slave is a terminal for the // console (stdin and stdout), input written to the master reaches the // slave's stdin, and everything the slave prints is collected. func openPTY(t *testing.T) (master, slave *os.File, output *ptyOutput) { t.Helper() m, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY, 0) if err != nil { t.Fatalf("open /dev/ptmx: %v", err) } var unlock int32 if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, m.Fd(), syscall.TIOCSPTLCK, uintptr(unsafe.Pointer(&unlock))); e != 0 { t.Fatalf("unlock pty: %v", e) } var n uint32 if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, m.Fd(), syscall.TIOCGPTN, uintptr(unsafe.Pointer(&n))); e != 0 { t.Fatalf("pty number: %v", e) } s, err := os.OpenFile(fmt.Sprintf("/dev/pts/%d", n), os.O_RDWR|syscall.O_NOCTTY, 0) if err != nil { t.Fatalf("open pty slave: %v", err) } output = &ptyOutput{} done := make(chan struct{}) go func() { defer close(done) buf := make([]byte, 4096) for { n, err := m.Read(buf) output.mu.Lock() output.buf.Write(buf[:n]) output.mu.Unlock() if err != nil { return } } }() t.Cleanup(func() { _ = s.Close() _ = m.Close() <-done }) return m, s, output } // productionApp is an app in the production environment without a DSN. func productionApp(t *testing.T, env string) *backpack.App { t.Helper() cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Env: env, Environ: []string{"SUMMER_ENV=" + env}}) if err != nil { t.Fatal(err) } return backpack.New(cfg) } // runQueueClear runs queue:clear with args on stdin and stdout of the // caller's choosing and returns the exit code and the error stream. func runQueueClear(t *testing.T, app *backpack.App, in io.Reader, out, errW io.Writer, args ...string) int { t.Helper() var clear bonfire.Command for _, c := range RuntimeCommands(app, nil) { if c.Name == "queue:clear" { clear = c } } if clear.Run == nil { t.Fatal("queue:clear not registered") } root, err := bonfire.NewRootIO("acme", []bonfire.Command{clear}, in, out, errW) if err != nil { t.Fatal(err) } return bonfire.Execute(context.Background(), root, append([]string{"queue:clear"}, args...)) } // TestQueueClearConfirm: in production queue:clear asks on an interactive // terminal and a "no" ends it with exit 1 and no error block, before the // database is opened; --force, -n, a non-production environment and a // non-terminal stdin skip the question (the run then reaches the database, // which these apps do not configure). func TestQueueClearConfirm(t *testing.T) { prod := productionApp(t, "production") master, slave, output := openPTY(t) if _, err := master.Write([]byte("n\n")); err != nil { t.Fatal(err) } if code := runQueueClear(t, prod, slave, slave, slave, "--no-ansi", "imports"); code != bonfire.ExitFailure { t.Fatalf("answer no: exit %d\n%s", code, output.String()) } waitFor(t, output, "Cancelled.") got := output.String() if !strings.Contains(got, `Delete every pending job of queue "imports" in production? [y/N]`) { t.Errorf("question not asked:\n%s", got) } if strings.Contains(got, "ERROR") || strings.Contains(got, "database.dsn") { t.Errorf("a cancelled run printed an error block or reached the database:\n%s", got) } for name, tc := range map[string]struct { app *backpack.App args []string }{ "--force": {prod, []string{"--no-ansi", "--force"}}, "-n": {prod, []string{"--no-ansi", "-n"}}, "not production": {productionApp(t, "staging"), []string{"--no-ansi"}}, } { master, slave, output := openPTY(t) if _, err := master.Write([]byte("n\n")); err != nil { t.Fatal(err) } if code := runQueueClear(t, tc.app, slave, slave, slave, tc.args...); code != bonfire.ExitFailure { t.Fatalf("%s: exit %d", name, code) } waitFor(t, output, "database.dsn") if strings.Contains(output.String(), "[y/N]") { t.Errorf("%s: the question was asked:\n%s", name, output.String()) } } // Off a terminal: no question, stdin is never read. in := strings.NewReader("n\n") var out, errW strings.Builder if code := runQueueClear(t, prod, in, &out, &errW); code != bonfire.ExitFailure { t.Fatalf("non-terminal: exit %d", code) } if in.Len() != 2 || strings.Contains(out.String(), "[y/N]") || !strings.Contains(errW.String(), "database.dsn") { t.Errorf("non-terminal run asked or read stdin (left %d):\n%s\n%s", in.Len(), out.String(), errW.String()) } } // waitFor waits until the pty output contains want. func waitFor(t *testing.T, output *ptyOutput, want string) { t.Helper() for range 200 { if strings.Contains(output.String(), want) { return } <-time.After(10 * time.Millisecond) } t.Fatalf("pty output lacks %q:\n%s", want, output.String()) }