mirror of https://github.com/coder/coder.git
fix: Run expect tests on Windows with conpty pseudo-terminal (#276)
This brings together a bunch of random, partially implemented packages for support of the new(ish) Windows [`conpty`](https://devblogs.microsoft.com/commandline/windows-command-line-introducing-the-windows-pseudo-console-conpty/) API - such that we can leverage the `expect` style of CLI tests, but in a way that works in Linux/OSX `pty`s and Windows `conpty`. These include: - Vendoring the `go-expect` library from Netflix w/ some tweaks to work cross-platform - Vendoring the `pty` cross-platform implementation from [waypoint-plugin-sdk](b55c787a65/internal/pkg/pty
) - Vendoring the `conpty` Windows-specific implementation from [waypoint-plugin-sdk](b55c787a65/internal/pkg/conpty
) - Adjusting the `pty` interface to work with `go-expect` + the cross-plat version There were several limitations with the current packages: - `go-expect` requires the same `os.File` (TTY) for input / output, but `conhost` requires separate file handles - `conpty` does not handle input, only output - The cross-platform `pty` didn't expose the full set of primitives needed for `console` Therefore, the following changes were made: - Handling of `stdin` was added to the `conpty` interface - We weren't using the full extent of the `go-expect` interface, so some portions were removed (ie, exec'ing a process) to simplify our implementation and make it easier to extend cross-platform - Instead of `console` exposing just a `Tty`, it exposes an `InTty` and `OutTty`, to help encapsulate the difference on Windows (on Linux, these point to the same pipe) Future improvements: - The `isatty` implementation doesn't support accurate detection of `conhost` pty's without an associated process. In lieu of a more robust check, I've added a `--force-tty` flag intended for test case use - that forces the CLI to run in tty mode. - It seems the windows implementation doesn't support setting a deadline. This is needed for the expect.Timeout API, but isn't used by us yet. Fixes #241
This commit is contained in:
parent
64c14de7fe
commit
c9c03123eb
|
@ -2,16 +2,13 @@ package clitest
|
|||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/Netflix/go-expect"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
|
@ -21,12 +18,6 @@ import (
|
|||
"github.com/coder/coder/provisioner/echo"
|
||||
)
|
||||
|
||||
var (
|
||||
// Used to ensure terminal output doesn't have anything crazy!
|
||||
// See: https://stackoverflow.com/a/29497680
|
||||
stripAnsi = regexp.MustCompile("[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))")
|
||||
)
|
||||
|
||||
// New creates a CLI instance with a configuration pointed to a
|
||||
// temporary testing directory.
|
||||
func New(t *testing.T, args ...string) (*cobra.Command, config.Root) {
|
||||
|
@ -55,31 +46,6 @@ func CreateProjectVersionSource(t *testing.T, responses *echo.Responses) string
|
|||
return directory
|
||||
}
|
||||
|
||||
// NewConsole creates a new TTY bound to the command provided.
|
||||
// All ANSI escape codes are stripped to provide clean output.
|
||||
func NewConsole(t *testing.T, cmd *cobra.Command) *expect.Console {
|
||||
reader, writer := io.Pipe()
|
||||
scanner := bufio.NewScanner(reader)
|
||||
t.Cleanup(func() {
|
||||
_ = reader.Close()
|
||||
_ = writer.Close()
|
||||
})
|
||||
go func() {
|
||||
for scanner.Scan() {
|
||||
if scanner.Err() != nil {
|
||||
return
|
||||
}
|
||||
t.Log(stripAnsi.ReplaceAllString(scanner.Text(), ""))
|
||||
}
|
||||
}()
|
||||
|
||||
console, err := expect.NewConsole(expect.WithStdout(writer))
|
||||
require.NoError(t, err)
|
||||
cmd.SetIn(console.Tty())
|
||||
cmd.SetOut(console.Tty())
|
||||
return console
|
||||
}
|
||||
|
||||
func extractTar(t *testing.T, data []byte, directory string) {
|
||||
reader := tar.NewReader(bytes.NewBuffer(data))
|
||||
for {
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
//go:build !windows
|
||||
|
||||
package clitest_test
|
||||
|
||||
import (
|
||||
|
@ -7,6 +5,7 @@ import (
|
|||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/expect"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
@ -21,7 +20,7 @@ func TestCli(t *testing.T) {
|
|||
client := coderdtest.New(t)
|
||||
cmd, config := clitest.New(t)
|
||||
clitest.SetupConfig(t, client, config)
|
||||
console := clitest.NewConsole(t, cmd)
|
||||
console := expect.NewTestConsole(t, cmd)
|
||||
go func() {
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
|
|
|
@ -22,6 +22,7 @@ func login() *cobra.Command {
|
|||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
rawURL := args[0]
|
||||
|
||||
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(rawURL, "localhost") {
|
||||
|
@ -44,7 +45,7 @@ func login() *cobra.Command {
|
|||
return xerrors.Errorf("has initial user: %w", err)
|
||||
}
|
||||
if !hasInitialUser {
|
||||
if !isTTY(cmd.InOrStdin()) {
|
||||
if !isTTY(cmd) {
|
||||
return xerrors.New("the initial user cannot be created in non-interactive mode. use the API")
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Your Coder deployment hasn't been set up!\n", color.HiBlackString(">"))
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
//go:build !windows
|
||||
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/expect"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
@ -23,8 +22,11 @@ func TestLogin(t *testing.T) {
|
|||
t.Run("InitialUserTTY", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t)
|
||||
root, _ := clitest.New(t, "login", client.URL.String())
|
||||
console := clitest.NewConsole(t, root)
|
||||
// The --force-tty flag is required on Windows, because the `isatty` library does not
|
||||
// accurately detect Windows ptys when they are not attached to a process:
|
||||
// https://github.com/mattn/go-isatty/issues/59
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--force-tty")
|
||||
console := expect.NewTestConsole(t, root)
|
||||
go func() {
|
||||
err := root.Execute()
|
||||
require.NoError(t, err)
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
//go:build !windows
|
||||
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
|
@ -10,6 +8,7 @@ import (
|
|||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/database"
|
||||
"github.com/coder/coder/expect"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
)
|
||||
|
@ -27,7 +26,7 @@ func TestProjectCreate(t *testing.T) {
|
|||
cmd, root := clitest.New(t, "projects", "create", "--directory", source, "--provisioner", string(database.ProvisionerTypeEcho))
|
||||
clitest.SetupConfig(t, client, root)
|
||||
_ = coderdtest.NewProvisionerDaemon(t, client)
|
||||
console := clitest.NewConsole(t, cmd)
|
||||
console := expect.NewTestConsole(t, cmd)
|
||||
closeChan := make(chan struct{})
|
||||
go func() {
|
||||
err := cmd.Execute()
|
||||
|
@ -74,7 +73,7 @@ func TestProjectCreate(t *testing.T) {
|
|||
cmd, root := clitest.New(t, "projects", "create", "--directory", source, "--provisioner", string(database.ProvisionerTypeEcho))
|
||||
clitest.SetupConfig(t, client, root)
|
||||
coderdtest.NewProvisionerDaemon(t, client)
|
||||
console := clitest.NewConsole(t, cmd)
|
||||
console := expect.NewTestConsole(t, cmd)
|
||||
closeChan := make(chan struct{})
|
||||
go func() {
|
||||
err := cmd.Execute()
|
||||
|
|
18
cli/root.go
18
cli/root.go
|
@ -21,6 +21,7 @@ import (
|
|||
|
||||
const (
|
||||
varGlobalConfig = "global-config"
|
||||
varForceTty = "force-tty"
|
||||
)
|
||||
|
||||
func Root() *cobra.Command {
|
||||
|
@ -65,6 +66,12 @@ func Root() *cobra.Command {
|
|||
cmd.AddCommand(users())
|
||||
|
||||
cmd.PersistentFlags().String(varGlobalConfig, configdir.LocalConfig("coder"), "Path to the global `coder` config directory")
|
||||
cmd.PersistentFlags().Bool(varForceTty, false, "Force the `coder` command to run as if connected to a TTY")
|
||||
err := cmd.PersistentFlags().MarkHidden(varForceTty)
|
||||
if err != nil {
|
||||
// This should never return an error, because we just added the `--force-tty`` flag prior to calling MarkHidden.
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
@ -113,7 +120,16 @@ func createConfig(cmd *cobra.Command) config.Root {
|
|||
// isTTY returns whether the passed reader is a TTY or not.
|
||||
// This accepts a reader to work with Cobra's "InOrStdin"
|
||||
// function for simple testing.
|
||||
func isTTY(reader io.Reader) bool {
|
||||
func isTTY(cmd *cobra.Command) bool {
|
||||
// If the `--force-tty` command is available, and set,
|
||||
// assume we're in a tty. This is primarily for cases on Windows
|
||||
// where we may not be able to reliably detect this automatically (ie, tests)
|
||||
forceTty, err := cmd.Flags().GetBool(varForceTty)
|
||||
if forceTty && err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
reader := cmd.InOrStdin()
|
||||
file, ok := reader.(*os.File)
|
||||
if !ok {
|
||||
return false
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
//go:build !windows
|
||||
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
|
@ -7,6 +5,7 @@ import (
|
|||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/expect"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
@ -37,7 +36,7 @@ func TestWorkspaceCreate(t *testing.T) {
|
|||
cmd, root := clitest.New(t, "workspaces", "create", project.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
console := clitest.NewConsole(t, cmd)
|
||||
console := expect.NewTestConsole(t, cmd)
|
||||
closeChan := make(chan struct{})
|
||||
go func() {
|
||||
err := cmd.Execute()
|
||||
|
|
|
@ -0,0 +1,107 @@
|
|||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
// Original copyright 2020 ActiveState Software. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file
|
||||
|
||||
package conpty
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// ConPty represents a windows pseudo console.
|
||||
type ConPty struct {
|
||||
hpCon windows.Handle
|
||||
outPipePseudoConsoleSide windows.Handle
|
||||
outPipeOurSide windows.Handle
|
||||
inPipeOurSide windows.Handle
|
||||
inPipePseudoConsoleSide windows.Handle
|
||||
consoleSize uintptr
|
||||
outFilePseudoConsoleSide *os.File
|
||||
outFileOurSide *os.File
|
||||
inFilePseudoConsoleSide *os.File
|
||||
inFileOurSide *os.File
|
||||
closed bool
|
||||
}
|
||||
|
||||
// New returns a new ConPty pseudo terminal device
|
||||
func New(columns int16, rows int16) (*ConPty, error) {
|
||||
c := &ConPty{
|
||||
consoleSize: uintptr(columns) + (uintptr(rows) << 16),
|
||||
}
|
||||
|
||||
return c, c.createPseudoConsoleAndPipes()
|
||||
}
|
||||
|
||||
// Close closes the pseudo-terminal and cleans up all attached resources
|
||||
func (c *ConPty) Close() error {
|
||||
// Trying to close these pipes multiple times will result in an
|
||||
// access violation
|
||||
if c.closed {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := closePseudoConsole(c.hpCon)
|
||||
c.outFilePseudoConsoleSide.Close()
|
||||
c.outFileOurSide.Close()
|
||||
c.inFilePseudoConsoleSide.Close()
|
||||
c.inFileOurSide.Close()
|
||||
c.closed = true
|
||||
return err
|
||||
}
|
||||
|
||||
// OutPipe returns the output pipe of the pseudo terminal
|
||||
func (c *ConPty) OutPipe() *os.File {
|
||||
return c.outFilePseudoConsoleSide
|
||||
}
|
||||
|
||||
func (c *ConPty) Reader() io.Reader {
|
||||
return c.outFileOurSide
|
||||
}
|
||||
|
||||
// InPipe returns input pipe of the pseudo terminal
|
||||
// Note: It is safer to use the Write method to prevent partially-written VT sequences
|
||||
// from corrupting the terminal
|
||||
func (c *ConPty) InPipe() *os.File {
|
||||
return c.inFilePseudoConsoleSide
|
||||
}
|
||||
|
||||
func (c *ConPty) WriteString(str string) (int, error) {
|
||||
return c.inFileOurSide.WriteString(str)
|
||||
}
|
||||
|
||||
func (c *ConPty) createPseudoConsoleAndPipes() error {
|
||||
// Create the stdin pipe
|
||||
if err := windows.CreatePipe(&c.inPipePseudoConsoleSide, &c.inPipeOurSide, nil, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create the stdout pipe
|
||||
if err := windows.CreatePipe(&c.outPipeOurSide, &c.outPipePseudoConsoleSide, nil, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create the pty with our stdin/stdout
|
||||
if err := createPseudoConsole(c.consoleSize, c.inPipePseudoConsoleSide, c.outPipePseudoConsoleSide, &c.hpCon); err != nil {
|
||||
return fmt.Errorf("failed to create pseudo console: %d, %v", uintptr(c.hpCon), err)
|
||||
}
|
||||
|
||||
c.outFilePseudoConsoleSide = os.NewFile(uintptr(c.outPipePseudoConsoleSide), "|0")
|
||||
c.outFileOurSide = os.NewFile(uintptr(c.outPipeOurSide), "|1")
|
||||
|
||||
c.inFilePseudoConsoleSide = os.NewFile(uintptr(c.inPipePseudoConsoleSide), "|2")
|
||||
c.inFileOurSide = os.NewFile(uintptr(c.inPipeOurSide), "|3")
|
||||
c.closed = false
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ConPty) Resize(cols uint16, rows uint16) error {
|
||||
return resizePseudoConsole(c.hpCon, uintptr(cols)+(uintptr(rows)<<16))
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
// Copyright 2020 ActiveState Software. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file
|
||||
|
||||
package conpty
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||
procResizePseudoConsole = kernel32.NewProc("ResizePseudoConsole")
|
||||
procCreatePseudoConsole = kernel32.NewProc("CreatePseudoConsole")
|
||||
procClosePseudoConsole = kernel32.NewProc("ClosePseudoConsole")
|
||||
)
|
||||
|
||||
func createPseudoConsole(consoleSize uintptr, ptyIn windows.Handle, ptyOut windows.Handle, hpCon *windows.Handle) (err error) {
|
||||
r1, _, e1 := procCreatePseudoConsole.Call(
|
||||
consoleSize,
|
||||
uintptr(ptyIn),
|
||||
uintptr(ptyOut),
|
||||
0,
|
||||
uintptr(unsafe.Pointer(hpCon)),
|
||||
)
|
||||
|
||||
if r1 != 0 { // !S_OK
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func resizePseudoConsole(handle windows.Handle, consoleSize uintptr) (err error) {
|
||||
r1, _, e1 := procResizePseudoConsole.Call(uintptr(handle), consoleSize)
|
||||
if r1 != 0 { // !S_OK
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func closePseudoConsole(handle windows.Handle) (err error) {
|
||||
r1, _, e1 := procClosePseudoConsole.Call(uintptr(handle))
|
||||
if r1 == 0 {
|
||||
err = e1
|
||||
}
|
||||
|
||||
return
|
||||
}
|
|
@ -0,0 +1,163 @@
|
|||
// Copyright 2018 Netflix, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package expect
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/coder/coder/expect/pty"
|
||||
)
|
||||
|
||||
// Console is an interface to automate input and output for interactive
|
||||
// applications. Console can block until a specified output is received and send
|
||||
// input back on it's tty. Console can also multiplex other sources of input
|
||||
// and multiplex its output to other writers.
|
||||
type Console struct {
|
||||
opts ConsoleOpts
|
||||
pty pty.Pty
|
||||
runeReader *bufio.Reader
|
||||
closers []io.Closer
|
||||
}
|
||||
|
||||
// ConsoleOpt allows setting Console options.
|
||||
type ConsoleOpt func(*ConsoleOpts) error
|
||||
|
||||
// ConsoleOpts provides additional options on creating a Console.
|
||||
type ConsoleOpts struct {
|
||||
Logger *log.Logger
|
||||
Stdouts []io.Writer
|
||||
ExpectObservers []Observer
|
||||
}
|
||||
|
||||
// Observer provides an interface for a function callback that will
|
||||
// be called after each Expect operation.
|
||||
// matchers will be the list of active matchers when an error occurred,
|
||||
// or a list of matchers that matched `buf` when err is nil.
|
||||
// buf is the captured output that was matched against.
|
||||
// err is error that might have occurred. May be nil.
|
||||
type Observer func(matchers []Matcher, buf string, err error)
|
||||
|
||||
// WithStdout adds writers that Console duplicates writes to, similar to the
|
||||
// Unix tee(1) command.
|
||||
//
|
||||
// Each write is written to each listed writer, one at a time. Console is the
|
||||
// last writer, writing to it's internal buffer for matching expects.
|
||||
// If a listed writer returns an error, that overall write operation stops and
|
||||
// returns the error; it does not continue down the list.
|
||||
func WithStdout(writers ...io.Writer) ConsoleOpt {
|
||||
return func(opts *ConsoleOpts) error {
|
||||
opts.Stdouts = append(opts.Stdouts, writers...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger adds a logger for Console to log debugging information to. By
|
||||
// default Console will discard logs.
|
||||
func WithLogger(logger *log.Logger) ConsoleOpt {
|
||||
return func(opts *ConsoleOpts) error {
|
||||
opts.Logger = logger
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithExpectObserver adds an ExpectObserver to allow monitoring Expect operations.
|
||||
func WithExpectObserver(observers ...Observer) ConsoleOpt {
|
||||
return func(opts *ConsoleOpts) error {
|
||||
opts.ExpectObservers = append(opts.ExpectObservers, observers...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// NewConsole returns a new Console with the given options.
|
||||
func NewConsole(opts ...ConsoleOpt) (*Console, error) {
|
||||
options := ConsoleOpts{
|
||||
Logger: log.New(ioutil.Discard, "", 0),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
if err := opt(&options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
consolePty, err := pty.New()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
closers := []io.Closer{consolePty}
|
||||
reader := consolePty.Reader()
|
||||
|
||||
console := &Console{
|
||||
opts: options,
|
||||
pty: consolePty,
|
||||
runeReader: bufio.NewReaderSize(reader, utf8.UTFMax),
|
||||
closers: closers,
|
||||
}
|
||||
|
||||
return console, nil
|
||||
}
|
||||
|
||||
// Tty returns an input Tty for accepting input
|
||||
func (c *Console) InTty() *os.File {
|
||||
return c.pty.InPipe()
|
||||
}
|
||||
|
||||
// OutTty returns an output tty for writing
|
||||
func (c *Console) OutTty() *os.File {
|
||||
return c.pty.OutPipe()
|
||||
}
|
||||
|
||||
// Close closes Console's tty. Calling Close will unblock Expect and ExpectEOF.
|
||||
func (c *Console) Close() error {
|
||||
for _, fd := range c.closers {
|
||||
err := fd.Close()
|
||||
if err != nil {
|
||||
c.Logf("failed to close: %s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send writes string s to Console's tty.
|
||||
func (c *Console) Send(s string) (int, error) {
|
||||
c.Logf("console send: %q", s)
|
||||
n, err := c.pty.WriteString(s)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// SendLine writes string s to Console's tty with a trailing newline.
|
||||
func (c *Console) SendLine(s string) (int, error) {
|
||||
bytes, err := c.Send(fmt.Sprintf("%s\n", s))
|
||||
|
||||
return bytes, err
|
||||
}
|
||||
|
||||
// Log prints to Console's logger.
|
||||
// Arguments are handled in the manner of fmt.Print.
|
||||
func (c *Console) Log(v ...interface{}) {
|
||||
c.opts.Logger.Print(v...)
|
||||
}
|
||||
|
||||
// Logf prints to Console's logger.
|
||||
// Arguments are handled in the manner of fmt.Printf.
|
||||
func (c *Console) Logf(format string, v ...interface{}) {
|
||||
c.opts.Logger.Printf(format, v...)
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
// Copyright 2018 Netflix, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package expect provides an expect-like interface to automate control of
|
||||
// applications. It is unlike expect in that it does not spawn or manage
|
||||
// process lifecycle. This package only focuses on expecting output and sending
|
||||
// input through it's psuedoterminal.
|
||||
package expect
|
|
@ -0,0 +1,109 @@
|
|||
// Copyright 2018 Netflix, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package expect
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Expectf reads from the Console's tty until the provided formatted string
|
||||
// is read or an error occurs, and returns the buffer read by Console.
|
||||
func (c *Console) Expectf(format string, args ...interface{}) (string, error) {
|
||||
return c.Expect(String(fmt.Sprintf(format, args...)))
|
||||
}
|
||||
|
||||
// ExpectString reads from Console's tty until the provided string is read or
|
||||
// an error occurs, and returns the buffer read by Console.
|
||||
func (c *Console) ExpectString(s string) (string, error) {
|
||||
return c.Expect(String(s))
|
||||
}
|
||||
|
||||
// Expect reads from Console's tty until a condition specified from opts is
|
||||
// encountered or an error occurs, and returns the buffer read by console.
|
||||
// No extra bytes are read once a condition is met, so if a program isn't
|
||||
// expecting input yet, it will be blocked. Sends are queued up in tty's
|
||||
// internal buffer so that the next Expect will read the remaining bytes (i.e.
|
||||
// rest of prompt) as well as its conditions.
|
||||
func (c *Console) Expect(opts ...Opt) (string, error) {
|
||||
var options Opts
|
||||
for _, opt := range opts {
|
||||
if err := opt(&options); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
writer := io.MultiWriter(append(c.opts.Stdouts, buf)...)
|
||||
runeWriter := bufio.NewWriterSize(writer, utf8.UTFMax)
|
||||
|
||||
var matcher Matcher
|
||||
var err error
|
||||
|
||||
defer func() {
|
||||
for _, observer := range c.opts.ExpectObservers {
|
||||
if matcher != nil {
|
||||
observer([]Matcher{matcher}, buf.String(), err)
|
||||
return
|
||||
}
|
||||
observer(options.Matchers, buf.String(), err)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
var r rune
|
||||
r, _, err = c.runeReader.ReadRune()
|
||||
if err != nil {
|
||||
matcher = options.Match(err)
|
||||
if matcher != nil {
|
||||
err = nil
|
||||
break
|
||||
}
|
||||
return buf.String(), err
|
||||
}
|
||||
|
||||
c.Logf("expect read: %q", string(r))
|
||||
_, err = runeWriter.WriteRune(r)
|
||||
if err != nil {
|
||||
return buf.String(), err
|
||||
}
|
||||
|
||||
// Immediately flush rune to the underlying writers.
|
||||
err = runeWriter.Flush()
|
||||
if err != nil {
|
||||
return buf.String(), err
|
||||
}
|
||||
|
||||
matcher = options.Match(buf)
|
||||
if matcher != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if matcher != nil {
|
||||
cb, ok := matcher.(CallbackMatcher)
|
||||
if ok {
|
||||
err = cb.Callback(buf)
|
||||
if err != nil {
|
||||
return buf.String(), err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buf.String(), err
|
||||
}
|
|
@ -0,0 +1,139 @@
|
|||
// Copyright 2018 Netflix, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package expect
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Opt allows settings Expect options.
|
||||
type Opt func(*Opts) error
|
||||
|
||||
// ConsoleCallback is a callback function to execute if a match is found for
|
||||
// the chained matcher.
|
||||
type ConsoleCallback func(buf *bytes.Buffer) error
|
||||
|
||||
// Opts provides additional options on Expect.
|
||||
type Opts struct {
|
||||
Matchers []Matcher
|
||||
ReadTimeout *time.Duration
|
||||
}
|
||||
|
||||
// Match sequentially calls Match on all matchers in ExpectOpts and returns the
|
||||
// first matcher if a match exists, otherwise nil.
|
||||
func (eo Opts) Match(v interface{}) Matcher {
|
||||
for _, matcher := range eo.Matchers {
|
||||
if matcher.Match(v) {
|
||||
return matcher
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CallbackMatcher is a matcher that provides a Callback function.
|
||||
type CallbackMatcher interface {
|
||||
// Callback executes the matcher's callback with the content buffer at the
|
||||
// time of match.
|
||||
Callback(buf *bytes.Buffer) error
|
||||
}
|
||||
|
||||
// Matcher provides an interface for finding a match in content read from
|
||||
// Console's tty.
|
||||
type Matcher interface {
|
||||
// Match returns true iff a match is found.
|
||||
Match(v interface{}) bool
|
||||
Criteria() interface{}
|
||||
}
|
||||
|
||||
// stringMatcher fulfills the Matcher interface to match strings against a given
|
||||
// bytes.Buffer.
|
||||
type stringMatcher struct {
|
||||
str string
|
||||
}
|
||||
|
||||
func (sm *stringMatcher) Match(v interface{}) bool {
|
||||
buf, ok := v.(*bytes.Buffer)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(buf.String(), sm.str) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (sm *stringMatcher) Criteria() interface{} {
|
||||
return sm.str
|
||||
}
|
||||
|
||||
// allMatcher fulfills the Matcher interface to match a group of ExpectOpt
|
||||
// against any value.
|
||||
type allMatcher struct {
|
||||
options Opts
|
||||
}
|
||||
|
||||
func (am *allMatcher) Match(v interface{}) bool {
|
||||
var matchers []Matcher
|
||||
for _, matcher := range am.options.Matchers {
|
||||
if matcher.Match(v) {
|
||||
continue
|
||||
}
|
||||
matchers = append(matchers, matcher)
|
||||
}
|
||||
|
||||
am.options.Matchers = matchers
|
||||
return len(matchers) == 0
|
||||
}
|
||||
|
||||
func (am *allMatcher) Criteria() interface{} {
|
||||
var criteria []interface{}
|
||||
for _, matcher := range am.options.Matchers {
|
||||
criteria = append(criteria, matcher.Criteria())
|
||||
}
|
||||
return criteria
|
||||
}
|
||||
|
||||
// All adds an Expect condition to exit if the content read from Console's tty
|
||||
// matches all of the provided ExpectOpt, in any order.
|
||||
func All(expectOpts ...Opt) Opt {
|
||||
return func(opts *Opts) error {
|
||||
var options Opts
|
||||
for _, opt := range expectOpts {
|
||||
if err := opt(&options); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
opts.Matchers = append(opts.Matchers, &allMatcher{
|
||||
options: options,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// String adds an Expect condition to exit if the content read from Console's
|
||||
// tty contains any of the given strings.
|
||||
func String(strs ...string) Opt {
|
||||
return func(opts *Opts) error {
|
||||
for _, str := range strs {
|
||||
opts.Matchers = append(opts.Matchers, &stringMatcher{
|
||||
str: str,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
|
@ -0,0 +1,163 @@
|
|||
// Copyright 2018 Netflix, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package expect_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
. "github.com/coder/coder/expect"
|
||||
)
|
||||
|
||||
func TestExpectOptString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
title string
|
||||
opt Opt
|
||||
data string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
"No args",
|
||||
String(),
|
||||
"Hello world",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"Single arg",
|
||||
String("Hello"),
|
||||
"Hello world",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"Multiple arg",
|
||||
String("other", "world"),
|
||||
"Hello world",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"No matches",
|
||||
String("hello"),
|
||||
"Hello world",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.title, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var options Opts
|
||||
err := test.opt(&options)
|
||||
require.Nil(t, err)
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
_, err = buf.WriteString(test.data)
|
||||
require.Nil(t, err)
|
||||
|
||||
matcher := options.Match(buf)
|
||||
if test.expected {
|
||||
require.NotNil(t, matcher)
|
||||
} else {
|
||||
require.Nil(t, matcher)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpectOptAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
title string
|
||||
opt Opt
|
||||
data string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
"No opts",
|
||||
All(),
|
||||
"Hello world",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"Single string match",
|
||||
All(String("Hello")),
|
||||
"Hello world",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"Single string no match",
|
||||
All(String("Hello")),
|
||||
"No match",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"Ordered strings match",
|
||||
All(String("Hello"), String("world")),
|
||||
"Hello world",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"Ordered strings not all match",
|
||||
All(String("Hello"), String("world")),
|
||||
"Hello",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"Unordered strings",
|
||||
All(String("world"), String("Hello")),
|
||||
"Hello world",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"Unordered strings not all match",
|
||||
All(String("world"), String("Hello")),
|
||||
"Hello",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"Repeated strings match",
|
||||
All(String("Hello"), String("Hello")),
|
||||
"Hello world",
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.title, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var options Opts
|
||||
err := test.opt(&options)
|
||||
require.Nil(t, err)
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
_, err = buf.WriteString(test.data)
|
||||
require.Nil(t, err)
|
||||
|
||||
matcher := options.Match(buf)
|
||||
if test.expected {
|
||||
require.NotNil(t, matcher)
|
||||
} else {
|
||||
require.Nil(t, matcher)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,181 @@
|
|||
// Copyright 2018 Netflix, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package expect_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
. "github.com/coder/coder/expect"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWrongAnswer = xerrors.New("wrong answer")
|
||||
)
|
||||
|
||||
type Survey struct {
|
||||
Prompt string
|
||||
Answer string
|
||||
}
|
||||
|
||||
func Prompt(in io.Reader, out io.Writer) error {
|
||||
reader := bufio.NewReader(in)
|
||||
|
||||
for _, survey := range []Survey{
|
||||
{
|
||||
"What is 1+1?", "2",
|
||||
},
|
||||
{
|
||||
"What is Netflix backwards?", "xilfteN",
|
||||
},
|
||||
} {
|
||||
_, err := fmt.Fprintf(out, "%s: ", survey.Prompt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = fmt.Fprint(out, text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if text != survey.Answer {
|
||||
return ErrWrongAnswer
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestConsole(t *testing.T, opts ...ConsoleOpt) (*Console, error) {
|
||||
opts = append([]ConsoleOpt{
|
||||
expectNoError(t),
|
||||
}, opts...)
|
||||
return NewConsole(opts...)
|
||||
}
|
||||
|
||||
func expectNoError(t *testing.T) ConsoleOpt {
|
||||
return WithExpectObserver(
|
||||
func(matchers []Matcher, buf string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if len(matchers) == 0 {
|
||||
t.Fatalf("Error occurred while matching %q: %s\n%s", buf, err, string(debug.Stack()))
|
||||
} else {
|
||||
var criteria []string
|
||||
for _, matcher := range matchers {
|
||||
criteria = append(criteria, fmt.Sprintf("%q", matcher.Criteria()))
|
||||
}
|
||||
t.Fatalf("Failed to find [%s] in %q: %s\n%s", strings.Join(criteria, ", "), buf, err, string(debug.Stack()))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func testCloser(t *testing.T, closer io.Closer) {
|
||||
if err := closer.Close(); err != nil {
|
||||
t.Errorf("Close failed: %s", err)
|
||||
debug.PrintStack()
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpectf(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
console, err := newTestConsole(t)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error but got'%s'", err)
|
||||
}
|
||||
defer testCloser(t, console)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
console.Expectf("What is 1+%d?", 1)
|
||||
console.SendLine("2")
|
||||
console.Expectf("What is %s backwards?", "Netflix")
|
||||
console.SendLine("xilfteN")
|
||||
}()
|
||||
|
||||
err = Prompt(console.InTty(), console.OutTty())
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error but got '%s'", err)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestExpect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
console, err := newTestConsole(t)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error but got'%s'", err)
|
||||
}
|
||||
defer testCloser(t, console)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
console.ExpectString("What is 1+1?")
|
||||
console.SendLine("2")
|
||||
console.ExpectString("What is Netflix backwards?")
|
||||
console.SendLine("xilfteN")
|
||||
}()
|
||||
|
||||
err = Prompt(console.InTty(), console.OutTty())
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error but got '%s'", err)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestExpectOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
console, err := newTestConsole(t)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error but got'%s'", err)
|
||||
}
|
||||
defer testCloser(t, console)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
console.ExpectString("What is 1+1?")
|
||||
console.SendLine("3")
|
||||
}()
|
||||
|
||||
err = Prompt(console.InTty(), console.OutTty())
|
||||
if err == nil || !errors.Is(err, ErrWrongAnswer) {
|
||||
t.Errorf("Expected error '%s' but got '%s' instead", ErrWrongAnswer, err)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package pty
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Pty is the minimal pseudo-tty interface we require.
|
||||
type Pty interface {
|
||||
InPipe() *os.File
|
||||
OutPipe() *os.File
|
||||
Resize(cols uint16, rows uint16) error
|
||||
WriteString(str string) (int, error)
|
||||
Reader() io.Reader
|
||||
Close() error
|
||||
}
|
||||
|
||||
// New creates a new Pty.
|
||||
func New() (Pty, error) {
|
||||
return newPty()
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package pty
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
func newPty() (Pty, error) {
|
||||
ptyFile, ttyFile, err := pty.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &unixPty{
|
||||
pty: ptyFile,
|
||||
tty: ttyFile,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type unixPty struct {
|
||||
pty, tty *os.File
|
||||
}
|
||||
|
||||
func (p *unixPty) InPipe() *os.File {
|
||||
return p.tty
|
||||
}
|
||||
|
||||
func (p *unixPty) OutPipe() *os.File {
|
||||
return p.tty
|
||||
}
|
||||
|
||||
func (p *unixPty) Reader() io.Reader {
|
||||
return p.pty
|
||||
}
|
||||
|
||||
func (p *unixPty) WriteString(str string) (int, error) {
|
||||
return p.pty.WriteString(str)
|
||||
}
|
||||
|
||||
func (p *unixPty) Resize(cols uint16, rows uint16) error {
|
||||
return pty.Setsize(p.tty, &pty.Winsize{
|
||||
Rows: rows,
|
||||
Cols: cols,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *unixPty) Close() error {
|
||||
err := p.pty.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = p.tty.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package pty
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"github.com/coder/coder/expect/conpty"
|
||||
)
|
||||
|
||||
func newPty() (Pty, error) {
|
||||
// We use the CreatePseudoConsole API which was introduced in build 17763
|
||||
vsn := windows.RtlGetVersion()
|
||||
if vsn.MajorVersion < 10 ||
|
||||
vsn.BuildNumber < 17763 {
|
||||
// If the CreatePseudoConsole API is not available, we fall back to a simpler
|
||||
// implementation that doesn't create an actual PTY - just uses os.Pipe
|
||||
return pipePty()
|
||||
}
|
||||
|
||||
return conpty.New(80, 80)
|
||||
}
|
||||
|
||||
func pipePty() (Pty, error) {
|
||||
inFilePipeSide, inFileOurSide, err := os.Pipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
outFileOurSide, outFilePipeSide, err := os.Pipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &pipePtyVal{
|
||||
inFilePipeSide,
|
||||
inFileOurSide,
|
||||
outFileOurSide,
|
||||
outFilePipeSide,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type pipePtyVal struct {
|
||||
inFilePipeSide, inFileOurSide *os.File
|
||||
outFileOurSide, outFilePipeSide *os.File
|
||||
}
|
||||
|
||||
func (p *pipePtyVal) InPipe() *os.File {
|
||||
return p.inFilePipeSide
|
||||
}
|
||||
|
||||
func (p *pipePtyVal) OutPipe() *os.File {
|
||||
return p.outFilePipeSide
|
||||
}
|
||||
|
||||
func (p *pipePtyVal) Reader() io.Reader {
|
||||
return p.outFileOurSide
|
||||
}
|
||||
|
||||
func (p *pipePtyVal) WriteString(str string) (int, error) {
|
||||
return p.inFileOurSide.WriteString(str)
|
||||
}
|
||||
|
||||
func (p *pipePtyVal) Resize(uint16, uint16) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *pipePtyVal) Close() error {
|
||||
p.inFileOurSide.Close()
|
||||
p.inFilePipeSide.Close()
|
||||
p.outFilePipeSide.Close()
|
||||
p.outFileOurSide.Close()
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package expect
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var (
|
||||
// Used to ensure terminal output doesn't have anything crazy!
|
||||
// See: https://stackoverflow.com/a/29497680
|
||||
stripAnsi = regexp.MustCompile("[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))")
|
||||
)
|
||||
|
||||
// NewTestConsole creates a new TTY bound to the command provided.
|
||||
// All ANSI escape codes are stripped to provide clean output.
|
||||
func NewTestConsole(t *testing.T, cmd *cobra.Command) *Console {
|
||||
reader, writer := io.Pipe()
|
||||
scanner := bufio.NewScanner(reader)
|
||||
t.Cleanup(func() {
|
||||
_ = reader.Close()
|
||||
_ = writer.Close()
|
||||
})
|
||||
go func() {
|
||||
for scanner.Scan() {
|
||||
if scanner.Err() != nil {
|
||||
return
|
||||
}
|
||||
t.Log(stripAnsi.ReplaceAllString(scanner.Text(), ""))
|
||||
}
|
||||
}()
|
||||
|
||||
console, err := NewConsole(WithStdout(writer))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
console.Close()
|
||||
})
|
||||
cmd.SetIn(console.InTty())
|
||||
cmd.SetOut(console.OutTty())
|
||||
return console
|
||||
}
|
Loading…
Reference in New Issue