//go:build linux package lagoon import ( "bytes" "context" "fmt" "os" "strings" "sync" "syscall" "testing" "time" "unsafe" "git.golem15.com/golem15/summercms/modules/bonfire" ) // ptyOutput collects what the program writes to a pseudo-terminal. type ptyOutput struct { mu sync.Mutex buf bytes.Buffer } 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 } // 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()) } // runOnPTY runs probe:confirm with args on a fresh pseudo-terminal after // typing answer, and returns the exit code and the terminal output. func runOnPTY(t *testing.T, env, answer string, args ...string) (int, *ptyOutput) { t.Helper() master, slave, output := openPTY(t) if _, err := master.Write([]byte(answer)); err != nil { t.Fatal(err) } root, err := bonfire.NewRootIO("acme", []bonfire.Command{probeConfirm(envApp(t, env, ""))}, slave, slave, slave) if err != nil { t.Fatal(err) } // --no-ansi keeps the line prompt (huh needs a terminal size). code := bonfire.Execute(context.Background(), root, append([]string{"--no-ansi", "probe:confirm"}, args...)) return code, output } // TestConfirmProductionInteractiveNo: on an interactive terminal in // production the question is asked; "n" or an empty answer prints the // cancel line and exits 1 without an error block (Reported); "y" proceeds. func TestConfirmProductionInteractiveNo(t *testing.T) { for _, answer := range []string{"n\n", "\n", "no\n"} { code, output := runOnPTY(t, "production", answer) if code != bonfire.ExitFailure { t.Fatalf("answer %q: exit %d\n%s", answer, code, output.String()) } waitFor(t, output, "Migration cancelled.") got := output.String() if !strings.Contains(got, "Apply 3 migrations to the production database? [y/N]") { t.Errorf("answer %q: question not asked:\n%s", answer, got) } if strings.Contains(got, "ERROR") || strings.Contains(got, "proceeded") { t.Errorf("answer %q: error block or the step ran:\n%s", answer, got) } } code, output := runOnPTY(t, "production", "y\n") if code != bonfire.ExitOK { t.Fatalf("answer y: exit %d\n%s", code, output.String()) } waitFor(t, output, "proceeded") } // TestForceSkipsConfirm: --force, -n and any environment other than // production proceed on an interactive terminal without asking (the typed // "n" is never read). func TestForceSkipsConfirm(t *testing.T) { for name, tc := range map[string]struct { env string args []string }{ "--force": {"production", []string{"--force"}}, "-n": {"production", []string{"-n"}}, "staging": {"staging", nil}, "development": {"development", nil}, } { code, output := runOnPTY(t, tc.env, "n\n", tc.args...) if code != bonfire.ExitOK { t.Fatalf("%s: exit %d\n%s", name, code, output.String()) } waitFor(t, output, "proceeded") if strings.Contains(output.String(), "[y/N]") { t.Errorf("%s: the question was asked:\n%s", name, output.String()) } } }