diff --git a/command/command.go b/command/command.go index 25308f7..057d043 100644 --- a/command/command.go +++ b/command/command.go @@ -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) @@ -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, @@ -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))...) } diff --git a/command/init_test.go b/command/init_test.go index 3a4dd52..de1104d 100644 --- a/command/init_test.go +++ b/command/init_test.go @@ -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) { @@ -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() { @@ -56,11 +60,12 @@ 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 @@ -68,47 +73,177 @@ func (s *InitTestSuite) TestTerminalInteraction() { 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"]) +} diff --git a/go.mod b/go.mod index 9c37679..da8b12c 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/test/interactive_terminal.go b/test/interactive_terminal.go index 798a8f6..39f8235 100644 --- a/test/interactive_terminal.go +++ b/test/interactive_terminal.go @@ -16,60 +16,138 @@ package test import ( "bytes" + "fmt" + "io" "os" "strings" "testing" "time" + "github.com/AlecAivazis/survey/v2/terminal" expect "github.com/Netflix/go-expect" "github.com/hinshun/vt10x" + "github.com/opsani/cli/command" "github.com/spf13/cobra" ) +// PassthroughPipeFile wraps a file with a PassthroughPipe to add read deadline support +type PassthroughPipeFile struct { + *expect.PassthroughPipe + file *os.File +} + +// Write proxies the write operation to the underlying file +func (s *PassthroughPipeFile) Write(p []byte) (n int, err error) { + return s.file.Write(p) +} + +// Fd proxies the file descriptor of the underyling file +// This is necessary because the survey library treats the stdio descriptors as +// concrete os.File instances instead of reader/writer interfaces +func (s *PassthroughPipeFile) Fd() uintptr { + return s.file.Fd() +} + +// NewPassthroughPipeFile returns a new PassthroughPipeFile that wraps the input file to enable read deadline support +func NewPassthroughPipeFile(inFile *os.File) (*PassthroughPipeFile, error) { + file := os.NewFile(inFile.Fd(), "pipe") + if file == nil { + return nil, fmt.Errorf("os.NewFile failed: is your file descriptor valid?") + } + pipe, err := expect.NewPassthroughPipe(inFile) + if err != nil { + return nil, err + } + return &PassthroughPipeFile{ + file: file, + PassthroughPipe: pipe, + }, nil +} + // RunTestInInteractiveTerminal runs a test within an interactive terminal environment -// Executin requires a standard test instance, a pair of functions that execute the code +// Execution requires a standard test instance, a pair of functions that execute the code // under test and the test code, and any desired options for configuring the virtual terminal environment func RunTestInInteractiveTerminal(t *testing.T, codeUnderTestFunc InteractiveProcessFunc, testFunc InteractiveUserFunc, consoleOpts ...expect.ConsoleOpt) (*InteractiveExecutionContext, error) { - context, err := ExecuteInInteractiveTerminal(codeUnderTestFunc, testFunc, consoleOpts...) - t.Logf("Raw output: %q", context.GetOutputBuffer().String()) - t.Logf("\n\nterminal state: %s", expect.StripTrailingEmptyLines(context.GetTerminalState().String())) + t.Logf("Raw output: %q", context.OutputBuffer().String()) + t.Logf("\n\nterminal state: %s", expect.StripTrailingEmptyLines(context.TerminalState().String())) return context, err } +// InteractiveExecutionContext describes the state of an interactive terminal execution type InteractiveExecutionContext struct { - outputBuffer *bytes.Buffer - terminalState *vt10x.State - console *expect.Console + outputBuffer *bytes.Buffer + terminalState *vt10x.State + console *expect.Console + passthroughTty *PassthroughPipeFile + closerProxy *closerProxy + consoleObserver *consoleObserver } -func (ice *InteractiveExecutionContext) GetStdin() *os.File { - return ice.console.Tty() +// ReadTimeout returns the read time for the process side of an interactive execution +// expect.Console takes care of establishing a proxy pipe on the master side of the Tty +// but in a unit testing situation we have read failures on the slave side where the process +// may be waiting for input from the user +func (ice *InteractiveExecutionContext) ReadTimeout() time.Duration { + return *ice.consoleObserver.readTimeout } -func (ice *InteractiveExecutionContext) GetStdout() *os.File { +// Tty returns the Tty of the underlying expect.Console instance +// You probably want to interact with the PassthroughTty which supports deadline based timeouts +func (ice *InteractiveExecutionContext) Tty() *os.File { return ice.console.Tty() } -func (ice *InteractiveExecutionContext) GetStderr() *os.File { - return ice.console.Tty() +// Stdin returns the io.Reader to be used as stdin during execution +func (ice *InteractiveExecutionContext) Stdin() io.Reader { + return ice.PassthroughTty() +} + +// Stdout returns the io.Writer to be used as stdout during execution +func (ice *InteractiveExecutionContext) Stdout() io.Writer { + return ice.PassthroughTty() +} + +// Stderr returns the io.Writer to be used as stdout during execution +func (ice *InteractiveExecutionContext) Stderr() io.Writer { + return ice.PassthroughTty() } -func (ice *InteractiveExecutionContext) GetOutputBuffer() *bytes.Buffer { +// OutputBuffer returns the output buffer read from the tty +func (ice *InteractiveExecutionContext) OutputBuffer() *bytes.Buffer { return ice.outputBuffer } -func (ice *InteractiveExecutionContext) GetTerminalState() *vt10x.State { +// TerminalState returns the state if the terminal +func (ice *InteractiveExecutionContext) TerminalState() *vt10x.State { return ice.terminalState } -func (ice *InteractiveExecutionContext) GetConsole() *expect.Console { +// Console returns the console for interacting with the terminal +func (ice *InteractiveExecutionContext) Console() *expect.Console { return ice.console } +// PassthroughTty returns a wrapper for the Tty that supports deadline based timeouts +// The Std* family of methods are all aliases for the passthrough tty +func (ice *InteractiveExecutionContext) PassthroughTty() *PassthroughPipeFile { + // Wrap the Tty into a PassthroughPipeFile to enable deadline support (NewPassthroughPipeFileReader??) + if ice.passthroughTty == nil { + passthroughTty, err := NewPassthroughPipeFile(ice.Tty()) + if err != nil { + panic(err) + } + ice.passthroughTty = passthroughTty + ice.closerProxy.target = passthroughTty + ice.consoleObserver.passthroughPipe = passthroughTty.PassthroughPipe + ice.consoleObserver.extendDeadline() + } + return ice.passthroughTty +} + // Args is a convenience function that converts a variadic list of strings into an array func Args(args ...string) []string { return args @@ -105,27 +183,83 @@ func (ice *InteractiveCommandExecutor) SetTimeout(timeout time.Duration) { ice.consoleOpts = append(ice.consoleOpts, expect.WithDefaultTimeout(timeout)) } +// Closes out the passthrough proxy +type closerProxy struct { + target io.Closer +} + +func (cp *closerProxy) Close() error { + if cp.target != nil { + return cp.target.Close() + } + return nil +} + +// Extends the read deadline on the passthrough pipe after expect/send operations +type consoleObserver struct { + passthroughPipe *expect.PassthroughPipe + readTimeout *time.Duration +} + +func (eo *consoleObserver) observeExpect(matchers []expect.Matcher, buf string, err error) { + if eo.passthroughPipe == nil || eo.readTimeout == nil || err != nil { + return + } + eo.extendDeadline() +} + +func (eo *consoleObserver) observeSend(msg string, num int, err error) { + if err != nil { + eo.extendDeadline() + } +} + +func (eo *consoleObserver) extendDeadline() { + if readTimeout := eo.readTimeout; readTimeout != nil { + err := eo.passthroughPipe.SetReadDeadline(time.Now().Add(*readTimeout)) + if err != nil { + panic(err) + } + } +} + // ExecuteInInteractiveTerminal runs a pair of functions connected in an interactive virtual terminal environment func ExecuteInInteractiveTerminal( processFunc InteractiveProcessFunc, // Represents the process that the user is interacting with via the terminal userFunc InteractiveUserFunc, // Represents the user interacting with the process consoleOpts ...expect.ConsoleOpt) (*InteractiveExecutionContext, error) { + consoleObserver := new(consoleObserver) + closerProxy := new(closerProxy) // Create a proxy object to close our Tty proxy later outputBuffer := new(bytes.Buffer) - console, terminalState, err := vt10x.NewVT10XConsole( - append([]expect.ConsoleOpt{ - expect.WithStdout(outputBuffer), - expect.WithDefaultTimeout(60 * time.Second), - }, consoleOpts...)...) + consoleOpts = append([]expect.ConsoleOpt{ + expect.WithStdout(outputBuffer), + expect.WithExpectObserver(consoleObserver.observeExpect), + expect.WithSendObserver(consoleObserver.observeSend), + expect.WithCloser(closerProxy), + expect.WithDefaultTimeout(250 * time.Millisecond), + }, consoleOpts...) + console, terminalState, err := vt10x.NewVT10XConsole(consoleOpts...) if err != nil { return nil, err } defer console.Close() + // Use the same timeout in effect on the slave (user) side on the master (process) side pf the PTY + timeoutOpts := expect.ConsoleOpts{} + for _, opt := range consoleOpts { + if err := opt(&timeoutOpts); err != nil { + return nil, err + } + } + consoleObserver.readTimeout = timeoutOpts.ReadTimeout + // Create the execution context executionContext := &InteractiveExecutionContext{ - outputBuffer: outputBuffer, - console: console, - terminalState: terminalState, + outputBuffer: outputBuffer, + console: console, + terminalState: terminalState, + closerProxy: closerProxy, + consoleObserver: consoleObserver, } // Execute our function within a channel and wait for exit @@ -137,6 +271,9 @@ func ExecuteInInteractiveTerminal( // Run the process for the user to interact with err = processFunc(executionContext) + if err != nil { + fmt.Println("Process failed", err) + } // Close the slave end of the pty, and read the remaining bytes from the master end. console.Tty().Close() @@ -145,15 +282,17 @@ func ExecuteInInteractiveTerminal( return executionContext, err } -// Execute runs the specified command interactively and returns an execution context object upon completion -func (ice *InteractiveCommandExecutor) Execute(args []string, interactionFunc InteractiveUserFunc) (*InteractiveExecutionContext, error) { +// ExecuteInteractively runs the specified command interactively and returns an execution context object upon completion +func (ice *InteractiveCommandExecutor) ExecuteInteractively(args []string, interactionFunc InteractiveUserFunc) (*InteractiveExecutionContext, error) { // Wrap our execution func with setup for Command execution commandExecutionFunc := func(context *InteractiveExecutionContext) error { - ice.command.SetIn(context.GetStdin()) - ice.command.SetOut(context.GetStdout()) - ice.command.SetErr(context.GetStderr()) + ice.command.SetIn(context.Stdin()) + ice.command.SetOut(context.Stdout()) + ice.command.SetErr(context.Stderr()) ice.command.SetArgs(args) + command.SetStdio(terminal.Stdio{In: context.PassthroughTty(), Out: context.PassthroughTty(), Err: context.PassthroughTty()}) + if ice.PreExecutionFunc != nil { ice.PreExecutionFunc(context) } @@ -167,9 +306,9 @@ func (ice *InteractiveCommandExecutor) Execute(args []string, interactionFunc In return ExecuteInInteractiveTerminal(commandExecutionFunc, interactionFunc, ice.consoleOpts...) } -// ExecuteString executes the target command by splitting the args string at space boundaries +// ExecuteStringInteractively executes the target command by splitting the args string at space boundaries // This is a convenience interface suitable only for simple arguments that do not contain quoted values or literals // If you need something more advanced please use the Execute() and Args() method to compose from a variadic list of arguments -func (ice *InteractiveCommandExecutor) ExecuteString(argsStr string, interactionFunc InteractiveUserFunc) (*InteractiveExecutionContext, error) { - return ice.Execute(strings.Split(argsStr, " "), interactionFunc) +func (ice *InteractiveCommandExecutor) ExecuteStringInteractively(argsStr string, interactionFunc InteractiveUserFunc) (*InteractiveExecutionContext, error) { + return ice.ExecuteInteractively(strings.Split(argsStr, " "), interactionFunc) }