Skip to content
This repository was archived by the owner on Jun 11, 2024. It is now read-only.

Interactive terminal testing #1

Merged
merged 22 commits into from
Apr 24, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 16 additions & 21 deletions command/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import (
// Command is a wrapper around cobra.Command that adds Opsani functionality
type Command struct {
*cobra.Command
stdio terminal.Stdio

// Shadow all Cobra functions with Opsani equivalents
PersistentPreRun func(cmd *Command, args []string)
Expand All @@ -54,26 +53,20 @@ type Command struct {
}

// Survey method wrappers
// NOTE: These are necessary because of how the Survey library models in, out, and err

func (cmd *Command) SetStdio(stdio terminal.Stdio) {
Stdio = stdio
cmd.stdio = stdio

// When stdio is set, cascade to Cobra
cmd.SetIn(stdio.In)
cmd.SetOut(stdio.Out)
cmd.SetErr(stdio.Err)
// Survey needs access to a file descriptor for configuring the terminal but Cobra wants to model
// stdio as streams.
var globalStdio terminal.Stdio

// SetStdio is global package helper for testing where access to a file
// descriptor for the TTY is required
func SetStdio(stdio terminal.Stdio) {
globalStdio = stdio
}

// TODO: Temporary global because of type issues
var Stdio terminal.Stdio

func (cmd *Command) GetStdio() terminal.Stdio {
if Stdio != (terminal.Stdio{}) {
return Stdio
} else if cmd.stdio != (terminal.Stdio{}) {
return cmd.stdio
// stdio is a test helper for returning terminal file descriptors usable by Survey
func (cmd *Command) stdio() terminal.Stdio {
if globalStdio != (terminal.Stdio{}) {
return globalStdio
} else {
return terminal.Stdio{
In: os.Stdin,
Expand All @@ -83,13 +76,15 @@ func (cmd *Command) GetStdio() terminal.Stdio {
}
}

// Ask is a wrapper for survey.AskOne that executes with the command's stdio
func (cmd *Command) Ask(qs []*survey.Question, response interface{}, opts ...survey.AskOpt) error {
stdio := cmd.GetStdio()
stdio := cmd.stdio()
return survey.Ask(qs, response, append(opts, survey.WithStdio(stdio.In, stdio.Out, stdio.Err))...)
}

// AskOne is a wrapper for survey.AskOne that executes with the command's stdio
func (cmd *Command) AskOne(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error {
stdio := cmd.GetStdio()
stdio := cmd.stdio()
return survey.AskOne(p, response, append(opts, survey.WithStdio(stdio.In, stdio.Out, stdio.Err))...)
}

Expand Down
189 changes: 162 additions & 27 deletions command/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,25 @@ package command_test

import (
"fmt"
"io/ioutil"
"testing"
"time"

"github.com/AlecAivazis/survey/v2"
"github.com/AlecAivazis/survey/v2/core"
"github.com/AlecAivazis/survey/v2/terminal"
expect "github.com/Netflix/go-expect"
"github.com/Netflix/go-expect"
"github.com/opsani/cli/command"
"github.com/opsani/cli/test"
"github.com/spf13/viper"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"gopkg.in/yaml.v2"
)

type InitTestSuite struct {
suite.Suite
*test.OpsaniCommandExecutor
*test.InteractiveCommandExecutor
}

func TestInitTestSuite(t *testing.T) {
Expand All @@ -45,6 +48,7 @@ func (s *InitTestSuite) SetupTest() {
rootCmd := command.NewRootCommand()

s.OpsaniCommandExecutor = test.NewOpsaniCommandExecutor(rootCmd)
s.InteractiveCommandExecutor = test.NewInteractiveCommandExecutor(rootCmd)
}

func (s *InitTestSuite) TestRunningInitHelp() {
Expand All @@ -56,59 +60,190 @@ func (s *InitTestSuite) TestRunningInitHelp() {
func (s *InitTestSuite) TestTerminalInteraction() {
var name string
test.RunTestInInteractiveTerminal(s.T(), func(context *test.InteractiveExecutionContext) error {
fmt.Printf("%v\n", context)
return survey.AskOne(&survey.Input{
Message: "What is your name?",
}, &name, survey.WithStdio(context.GetStdin(), context.GetStdout(), context.GetStderr()))
}, &name, survey.WithStdio(context.Tty(), context.Tty(), context.Tty()))
}, func(_ *test.InteractiveExecutionContext, c *expect.Console) error {
c.ExpectString("? What is your name?")
s.RequireNoErr2(c.ExpectString("What is your name?"))
c.SendLine("Blake Watters")
c.ExpectEOF()
return nil
})
s.Require().Equal(name, "Blake Watters")
}

func (s *InitTestSuite) RequireNoErr2(_ interface{}, err error) {
s.Require().NoError(err)
}

func (s *InitTestSuite) TestTerminalConfirm() {
var confirmed bool
var confirmed bool = true
test.RunTestInInteractiveTerminal(s.T(), func(context *test.InteractiveExecutionContext) error {
return survey.AskOne(&survey.Confirm{
Message: "Delete file?",
}, &confirmed, survey.WithStdio(context.GetStdin(), context.GetStdout(), context.GetStderr()))
}, &confirmed, survey.WithStdio(context.Tty(), context.Tty(), context.Tty()))
}, func(_ *test.InteractiveExecutionContext, c *expect.Console) error {
c.ExpectString("? Delete file?")
c.SendLine("Y")
s.RequireNoErr2(c.Expect(expect.RegexpPattern("Delete file?")))
c.SendLine("N")
c.ExpectEOF()
return nil
})
s.Require().True(confirmed)
s.Require().False(confirmed)
}

type InteractiveCommandTest struct {
console *expect.Console
context *test.InteractiveExecutionContext
s *InitTestSuite
}

func (ict *InteractiveCommandTest) Require() *require.Assertions {
return ict.s.Require()
}

func (ict *InteractiveCommandTest) SendLine(s string) (int, error) {
l, err := ict.console.SendLine(s)
ict.Require().NoError(err)
return l, err
}

func (ict *InteractiveCommandTest) ExpectEOF() (string, error) {
return ict.console.ExpectEOF()
}

func (ict *InteractiveCommandTest) ExpectString(s string) (string, error) {
return ict.console.ExpectString(s)
}

func (ict *InteractiveCommandTest) ExpectStringf(format string, args ...interface{}) (string, error) {
return ict.ExpectString(fmt.Sprintf(format, args...))
}

func (ict *InteractiveCommandTest) ExpectMatch(opts ...expect.ExpectOpt) (string, error) {
return ict.console.Expect(opts...)
}

func (ict *InteractiveCommandTest) ExpectMatches(opts ...expect.ExpectOpt) (string, error) {
return ict.console.Expect(opts...)
}

func (ict *InteractiveCommandTest) RequireEOF() (string, error) {
l, err := ict.console.ExpectEOF()
ict.Require().NoErrorf(err, "Unexpected error %q: - ", err, ict.context.OutputBuffer().String())
return l, err
}

func (ict *InteractiveCommandTest) RequireString(s string) (string, error) {
l, err := ict.console.ExpectString(s)
ict.Require().NoErrorf(err, "Failed while attempting to read %q: %v", s, err)
return l, err
}

func (ict *InteractiveCommandTest) RequireStringf(format string, args ...interface{}) (string, error) {
return ict.RequireString(fmt.Sprintf(format, args...))
}

func (ict *InteractiveCommandTest) RequireMatch(opts ...expect.ExpectOpt) (string, error) {
l, err := ict.console.Expect(opts...)
ict.Require().NoErrorf(err, "Failed while attempting to find a matcher for %q: %v", l, err)
return l, err
}

func (ict *InteractiveCommandTest) RequireMatches(opts ...expect.ExpectOpt) (string, error) {
l, err := ict.console.Expect(opts...)
ict.Require().NoErrorf(err, "Failed while attempting to find a matcher for %q: %v", l, err)
return l, err
}

func (s *InitTestSuite) TestInitWithExistingConfig() {
func (s *InitTestSuite) ExecuteCommandInteractivelyE(
ice *test.InteractiveCommandExecutor,
args []string,
testFunc func(*InteractiveCommandTest) error) (*test.InteractiveExecutionContext, error) {
return ice.ExecuteInteractively(args, func(context *test.InteractiveExecutionContext, console *expect.Console) error {
return testFunc(&InteractiveCommandTest{
console: console,
context: context,
s: s,
})
})
}

func (s *InitTestSuite) ExecuteCommandInteractively(
args []string,
testFunc func(*InteractiveCommandTest) error) (*test.InteractiveExecutionContext, error) {
return s.ExecuteInteractively(args, func(context *test.InteractiveExecutionContext, console *expect.Console) error {
return testFunc(&InteractiveCommandTest{
console: console,
context: context,
s: s,
})
})
}

func (s *InitTestSuite) TestInitWithExistingConfigDeclinedL() {
configFile := test.TempConfigFileWithObj(map[string]string{
"app": "example.com/app",
"token": "123456",
})

rootCmd := command.NewRootCommand()
ice := test.NewInteractiveCommandExecutor(rootCmd, expect.WithDefaultTimeout(1.0*time.Second))
ice.PreExecutionFunc = func(context *test.InteractiveExecutionContext) error {
// Attach the survey library to the console
// This is necessary because of type safety fun with modeling around file readers
command.Stdio = terminal.Stdio{context.GetStdin(), context.GetStdout(), context.GetStderr()}
context, err := s.ExecuteCommandInteractively(test.Args("--config", configFile.Name(), "init"), func(t *InteractiveCommandTest) error {
fmt.Printf("? Console = %v, Context = %v, t = %v, require = %v", t.console, t.context, t.s, t.s)
t.RequireStringf("Using config from: %s", configFile.Name())
t.RequireStringf("? Existing config found. Overwrite %s?", configFile.Name())
t.SendLine("N")
t.console.ExpectEOF()
return nil
}
_, err := ice.Execute(test.Args("--config", configFile.Name(), "init"), func(_ *test.InteractiveExecutionContext, console *expect.Console) error {
if _, err := console.ExpectString(fmt.Sprintf("Using config from: %s", configFile.Name())); err != nil {
return err
}
str := fmt.Sprintf("? Existing config found. Overwrite %s?", configFile.Name())
if _, err := console.ExpectString(str); err != nil {
return err
}
console.SendLine("N")
console.ExpectEOF()
})
s.T().Logf("%v", context.OutputBuffer().String())
s.Require().Error(err)
s.Require().EqualError(err, terminal.InterruptErr.Error())
}

func (s *InitTestSuite) TestInitWithExistingConfigDeclined() {
configFile := test.TempConfigFileWithObj(map[string]string{
"app": "example.com/app",
"token": "123456",
})

context, err := s.ExecuteCommandInteractively(test.Args("--config", configFile.Name(), "init"), func(t *InteractiveCommandTest) error {
t.RequireStringf("Using config from: %s", configFile.Name())
t.RequireStringf("? Existing config found. Overwrite %s?", configFile.Name())
t.SendLine("N")
t.ExpectEOF()
return nil
})
s.T().Logf("%v", context.OutputBuffer().String())
s.Require().Error(err)
s.Require().EqualError(err, terminal.InterruptErr.Error())
}

func (s *InitTestSuite) TestInitWithExistingConfigAccepted() {
configFile := test.TempConfigFileWithObj(map[string]string{
"app": "example.com/app",
"token": "123456",
})

context, err := s.ExecuteCommandInteractively(test.Args("--config", configFile.Name(), "init"), func(t *InteractiveCommandTest) error {
t.RequireStringf("Using config from: %s", configFile.Name())
t.RequireStringf("? Existing config found. Overwrite %s?", configFile.Name())
t.SendLine("Y")
t.ExpectMatch(expect.RegexpPattern("Opsani app"))
t.SendLine("dev.opsani.com/amazing-app")
t.RequireMatch(expect.RegexpPattern("API Token"))
t.SendLine("123456")
t.RequireMatch(expect.RegexpPattern(fmt.Sprintf("Write to %s?", configFile.Name())))

t.SendLine("Y")
t.RequireMatch(expect.RegexpPattern("Opsani CLI initialized"))
return nil
})
s.Require().NoError(err, context.OutputBuffer().String())

// Check the config file
var config = map[string]interface{}{}
body, err := ioutil.ReadFile(configFile.Name())
yaml.Unmarshal(body, &config)
s.Require().Equal("dev.opsani.com/amazing-app", config["app"])
s.Require().Equal("123456", config["token"])
}
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ require (
github.com/hokaccha/go-prettyjson v0.0.0-20190818114111-108c894c2c0e
github.com/imdario/mergo v0.3.9 // indirect
github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect
github.com/kr/pty v1.1.8 // indirect
github.com/kr/pty v1.1.8
github.com/mattn/go-isatty v0.0.12
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b
github.com/mitchellh/go-homedir v1.1.0
Expand All @@ -51,6 +51,7 @@ require (
google.golang.org/genproto v0.0.0-20200413115906-b5235f65be36 // indirect
google.golang.org/grpc v1.28.1 // indirect
gopkg.in/ini.v1 v1.55.0 // indirect
gopkg.in/yaml.v2 v2.2.8
gotest.tools v2.2.0+incompatible // indirect
k8s.io/apimachinery v0.18.1
k8s.io/client-go v0.18.1
Expand Down
Loading