Skip to content
This repository has been archived by the owner on Apr 19, 2024. It is now read-only.

Commit

Permalink
Refactor survey to use optionally specified stdio. (#143)
Browse files Browse the repository at this point in the history
* Refactor survey to use optionally specified stdio.

* Update cursor up down with terminal key arrows

* Update contributing, readme with go-expect tests and remove autoplay
  • Loading branch information
hinshun authored and AlecAivazis committed Jun 21, 2018
1 parent 4c5c08b commit db8e629
Show file tree
Hide file tree
Showing 385 changed files with 173,516 additions and 2,004 deletions.
29 changes: 24 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,36 @@ When submitting a contribution,
* Following community standards, add comments for all exported members so that all necessary information is available on godocs
* Remember to update the project README.md with changes to the high-level API
* Include both positive and negative unit tests (when applicable)
* Contributions with visual ramifications should be accompanied with an `autoplay` recording that will get verified on every PR. For more information on generating these recordings, see [Writing and Running Tests](#writing-and-running-tests)
* Contributions with visual ramifications or interaction changes should be accompanied with the appropriate `go-expect` tests. For more information on writing these tests, see [Writing and Running Tests](#writing-and-running-tests)


## Writing and running tests

When submitting features, please add as many units tests as necessary to test both positive and negative cases.

Given the current implementation of the library, functionality that is part of the prompt's output needs to be tested by recording an interaction and replaying that interaction with every change. The script and its recording should be committed to the `tests` directory. Bugs that are reported and reproduced should also get copied into this directory and recorded for future validation. This recording is done with a package called `autoplay`, created by one of `survey`'s maintainers.
Integration tests for survey uses [go-expect](https://github.com/Netflix/go-expect) to expect a match on stdout and respond on stdin. Since `os.Stdout` in a `go test` process is not a TTY, you need a way to interpret terminal / ANSI escape sequences for things like `CursorLocation`. The stdin/stdout handled by `go-expect` is also multiplexed to a [virtual terminal](https://github.com/hinshun/vt10x).

### Generating an autoplay test
For example, you can extend the tests for Input by specifying the following test case:

```go
{
"Test Input prompt interaction", // Name of the test.
&Input{ // An implementation of the survey.Prompt interface.
Message: "What is your name?",
},
func(c *expect.Console) { // An expect procedure. You can expect strings / regexps and
c.ExpectString("What is your name?") // write back strings / bytes to its psuedoterminal for survey.
c.SendLine("Johnny Appleseed")
c.ExpectEOF() // Nothing is read from the tty without an expect, and once an
// expectation is met, no further bytes are read. End your
// procedure with `c.ExpectEOF()` to read until survey finishes.
},
"Johnny Appleseed", // The expected result.
}
```

If you want to write your own `go-expect` test from scratch, you'll need to instantiate a virtual terminal,
multiplex it into an `*expect.Console`, and hook up its tty with survey's optional stdio. Please see `go-expect`
[documentation](https://godoc.org/github.com/Netflix/go-expect) for more detail.

`Autoplay` will record an interaction with the terminal and generate a script which executes the recording against the current terminal session. To install `autoplay`, run: `go get github.com/coryb/autoplay github.com/kr/pty`.

Once you have everything installed, navigate to the `tests/` directory and add a file with your test. I suggest running it first manually to ensure it behaves as you expect. Then, run `autoplay -n autoplay/<test name>.go go run <test name>.go`. Once the script is running, you should be greeted with whatever prompt you are testing. Interact with the terminal like normal, performing any sort of interaction that you want to verify. When the process ends, so will the recording and you should find a new file in `tests/autoplay/` with the recording. Commit this to the project alongside your PR.
72 changes: 72 additions & 0 deletions Gopkg.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 50 additions & 0 deletions Gopkg.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Gopkg.toml example
#
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
# for detailed Gopkg.toml documentation.
#
# required = ["github.com/user/thing/cmd/thing"]
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
#
# [[constraint]]
# name = "github.com/user/project"
# version = "1.0.0"
#
# [[constraint]]
# name = "github.com/user/project2"
# branch = "dev"
# source = "github.com/myfork/project2"
#
# [[override]]
# name = "github.com/x/y"
# version = "2.4.0"
#
# [prune]
# non-go = false
# go-tests = true
# unused-packages = true


[[constraint]]
branch = "master"
name = "github.com/Netflix/go-expect"

[[constraint]]
branch = "master"
name = "github.com/hinshun/vt10x"

[[constraint]]
name = "github.com/mattn/go-isatty"
version = "0.0.3"

[[constraint]]
branch = "master"
name = "github.com/mgutz/ansi"

[[constraint]]
name = "github.com/stretchr/testify"
version = "1.2.1"

[prune]
go-tests = true
unused-packages = true
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ func main() {
1. [Custom Types](#custom-types)
1. [Customizing Output](#customizing-output)
1. [Versioning](#versioning)
1. [Testing](#testing)

## Examples

Expand Down Expand Up @@ -299,3 +300,83 @@ package main

import "gopkg.in/AlecAivazis/survey.v1"
```

## Testing

You can test your program's interactive prompts using [go-expect](https://github.com/Netflix/go-expect). The library
can be used to expect a match on stdout and respond on stdin. Since `os.Stdout` in a `go test` process is not a TTY,
if you are manipulating the cursor or using `survey`, you will need a way to interpret terminal / ANSI escape sequences
for things like `CursorLocation`. `vt10x.NewVT10XConsole` will create a `go-expect` console that also multiplexes
stdio to an in-memory [virtual terminal](https://github.com/hinshun/vt10x).

For example, you can test a binary utilizing `survey` by connecting the Console's tty to a subprocess's stdio.

```go
func TestCLI(t *testing.T) {
// Multiplex stdin/stdout to a virtual terminal to respond to ANSI escape
// sequences (i.e. cursor position report).
c, state, err := vt10x.NewVT10XConsole()
require.Nil(t, err)
defer c.Close()

donec := make(chan struct{})
go func() {
defer close(donec)
c.ExpectString("What is your name?")
c.SendLine("Johnny Appleseed")
c.ExpectEOF()
}()

cmd := exec.Command("your-cli")
cmd.Stdin = c.Tty()
cmd.Stdout = c.Tty()
cmd.Stderr = c.Tty()

err = cmd.Run()
require.Nil(t, err)

// Close the slave end of the pty, and read the remaining bytes from the master end.
c.Tty().Close()
<-donec

// Dump the terminal's screen.
t.Log(expect.StripTrailingEmptyLines(state.String()))
}
```

If your application is decoupled from `os.Stdout` and `os.Stdin`, you can even test through the tty alone.
`survey` itself is tested in this manner.

```go
func TestCLI(t *testing.T) {
// Multiplex stdin/stdout to a virtual terminal to respond to ANSI escape
// sequences (i.e. cursor position report).
c, state, err := vt10x.NewVT10XConsole()
require.Nil(t, err)
defer c.Close()

donec := make(chan struct{})
go func() {
defer close(donec)
c.ExpectString("What is your name?")
c.SendLine("Johnny Appleseed")
c.ExpectEOF()
}()

prompt := &Input{
Message: "What is your name?",
}
prompt.WithStdio(Stdio(c))

answer, err := prompt.Prompt()
require.Nil(t, err)
require.Equal(t, "Johnny Appleseed", answer)

// Close the slave end of the pty, and read the remaining bytes from the master end.
c.Tty().Close()
<-donec

// Dump the terminal's screen.
t.Log(expect.StripTrailingEmptyLines(state.String()))
}
```
8 changes: 4 additions & 4 deletions confirm.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@ package survey

import (
"fmt"
"os"
"regexp"

"gopkg.in/AlecAivazis/survey.v1/core"
"gopkg.in/AlecAivazis/survey.v1/terminal"
)

// Confirm is a regular text input that accept yes/no answers. Response type is a bool.
Expand Down Expand Up @@ -50,17 +48,19 @@ func yesNo(t bool) string {
}

func (c *Confirm) getBool(showHelp bool) (bool, error) {
rr := terminal.NewRuneReader(os.Stdin)
cursor := c.NewCursor()
rr := c.NewRuneReader()
rr.SetTermMode()
defer rr.RestoreTermMode()

// start waiting for input
for {
line, err := rr.ReadLine(0)
if err != nil {
return false, err
}
// move back up a line to compensate for the \n echoed from terminal
terminal.CursorPreviousLine(1)
cursor.PreviousLine(1)
val := string(line)

// get the answer that matches the
Expand Down
84 changes: 78 additions & 6 deletions confirm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package survey

import (
"bytes"
"io"
"os"
"testing"

expect "github.com/Netflix/go-expect"
"github.com/stretchr/testify/assert"
"gopkg.in/AlecAivazis/survey.v1/core"
"gopkg.in/AlecAivazis/survey.v1/terminal"
Expand Down Expand Up @@ -55,17 +58,86 @@ func TestConfirmRender(t *testing.T) {
},
}

outputBuffer := bytes.NewBufferString("")
terminal.Stdout = outputBuffer

for _, test := range tests {
outputBuffer.Reset()
r, w, err := os.Pipe()
assert.Nil(t, err, test.title)

test.prompt.WithStdio(terminal.Stdio{Out: w})
test.data.Confirm = test.prompt
err := test.prompt.Render(
err = test.prompt.Render(
ConfirmQuestionTemplate,
test.data,
)
assert.Nil(t, err, test.title)
assert.Equal(t, test.expected, outputBuffer.String(), test.title)

w.Close()
var buf bytes.Buffer
io.Copy(&buf, r)

assert.Contains(t, buf.String(), test.expected, test.title)
}
}

func TestConfirmPrompt(t *testing.T) {
tests := []PromptTest{
{
"Test Confirm prompt interaction",
&Confirm{
Message: "Is pizza your favorite food?",
},
func(c *expect.Console) {
c.ExpectString("Is pizza your favorite food? (y/N)")
c.SendLine("n")
c.ExpectEOF()
},
false,
},
{
"Test Confirm prompt interaction with default",
&Confirm{
Message: "Is pizza your favorite food?",
Default: true,
},
func(c *expect.Console) {
c.ExpectString("Is pizza your favorite food? (Y/n)")
c.SendLine("")
c.ExpectEOF()
},
true,
},
{
"Test Confirm prompt interaction overriding default",
&Confirm{
Message: "Is pizza your favorite food?",
Default: true,
},
func(c *expect.Console) {
c.ExpectString("Is pizza your favorite food? (Y/n)")
c.SendLine("n")
c.ExpectEOF()
},
false,
},
{
"Test Confirm prompt interaction and prompt for help",
&Confirm{
Message: "Is pizza your favorite food?",
Help: "It probably is",
},
func(c *expect.Console) {
c.ExpectString("Is pizza your favorite food? [? for help] (y/N)")
c.SendLine("?")
c.ExpectString("It probably is")
c.SendLine("Y")
c.ExpectEOF()
},
true,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
RunPromptTest(t, test)
})
}
}
Loading

0 comments on commit db8e629

Please sign in to comment.