package cli_test import ( "bufio" "io" "os" "os/exec" "strings" "syscall" "testing" "time" "github.com/creack/pty" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/term" "sneak.berlin/go/keyfunc/internal/mnemonic" ) // TestEndOfInputAtThePromptEndsTheTool starts "age pub" and, at the // prompt, types Ctrl-D. The tool has to end with status 1, say that the // mnemonic is empty, and leave the terminal set as it found it, so with // echo on again. // // pty.Open returns the two ends of a new terminal: tty is the end the // tool uses as its terminal, and ptmx the end the test reads what the // terminal shows from and types into. func TestEndOfInputAtThePromptEndsTheTool(t *testing.T) { t.Setenv(mnemonic.Variable, "") t.Setenv(mnemonic.CommandVariable, "") ptmx, tty, err := pty.Open() require.NoError(t, err) defer func() { _ = ptmx.Close() }() defer func() { _ = tty.Close() }() before, err := term.GetState(int(tty.Fd())) require.NoError(t, err) command, terminal := prompted(t, ptmx, tty) _, err = ptmx.WriteString("\x04") // Ctrl-D require.NoError(t, err) waitForTool(t, "Ctrl-D at the prompt", command) require.Equal(t, failedStatus, command.ProcessState.ExitCode()) after, err := term.GetState(int(tty.Fd())) require.NoError(t, err) require.Equal(t, before, after, "the terminal settings were not restored") // With the tool ended and this end closed too, ptmx gives what is // left on the terminal and then an error. require.NoError(t, tty.Close()) said, _ := io.ReadAll(terminal) require.Contains(t, string(said), mnemonic.ErrEmpty.Error()) } // TestASignalAtThePromptPutsTheTerminalBack starts "age pub" and, at // the prompt, once the tool has turned echo off, sends it SIGINT. The // tool has to be ended by SIGINT, which a shell reports as status 130, // and leave the terminal set as it found it, so with echo on again. func TestASignalAtThePromptPutsTheTerminalBack(t *testing.T) { t.Setenv(mnemonic.Variable, "") t.Setenv(mnemonic.CommandVariable, "") ptmx, tty, err := pty.Open() require.NoError(t, err) defer func() { _ = ptmx.Close() }() defer func() { _ = tty.Close() }() before, err := term.GetState(int(tty.Fd())) require.NoError(t, err) command, _ := prompted(t, ptmx, tty) // The prompt is shown just before echo is turned off, and the // signal has to come while it is off. require.Eventually(t, func() bool { now, err := term.GetState(int(tty.Fd())) return err == nil && !assert.ObjectsAreEqual(before, now) }, 5*time.Second, 5*time.Millisecond, "the tool did not turn echo off") require.NoError(t, command.Process.Signal(syscall.SIGINT)) waitForTool(t, "SIGINT at the prompt", command) status, ok := command.ProcessState.Sys().(syscall.WaitStatus) require.True(t, ok) require.True(t, status.Signaled(), "the tool was not ended by the signal") require.Equal(t, syscall.SIGINT, status.Signal()) after, err := term.GetState(int(tty.Fd())) require.NoError(t, err) require.Equal(t, before, after, "the terminal settings were not restored") } // prompted starts "age pub" as a subprocess, for a test that has set no // mnemonic, with the terminal's tty end on all three streams, and // returns once the tool has shown the prompt. The reader returned reads // what the terminal shows from its ptmx end. Reading ptmx stops at a // deadline, so a prompt that never comes fails the test instead of // hanging it. func prompted(t *testing.T, ptmx, tty *os.File) (*exec.Cmd, *bufio.Reader) { t.Helper() require.NoError(t, ptmx.SetReadDeadline(time.Now().Add(10*time.Second))) //nolint:gosec // this test's own binary as the tool command := exec.CommandContext(t.Context(), os.Args[0], "age", "pub") command.Env = append(os.Environ(), runAsTool+"=1") command.Stdin = tty command.Stdout = tty command.Stderr = tty require.NoError(t, command.Start()) var shown string terminal := bufio.NewReader(ptmx) for !strings.HasSuffix(shown, "mnemonic: ") { char, err := terminal.ReadByte() require.NoError(t, err, "the tool did not prompt: %q", shown) shown += string(char) } return command, terminal }