Skip to content

Commit c9c0312

Browse files
authored
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](https://github.com/hashicorp/waypoint-plugin-sdk/tree/b55c787a65ff9b7d2b32cfae80681b78f8f2275e/internal/pkg/pty) - Vendoring the `conpty` Windows-specific implementation from [waypoint-plugin-sdk](https://github.com/hashicorp/waypoint-plugin-sdk/tree/b55c787a65ff9b7d2b32cfae80681b78f8f2275e/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
1 parent 64c14de commit c9c0312

19 files changed

+1173
-50
lines changed

cli/clitest/clitest.go

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,13 @@ package clitest
22

33
import (
44
"archive/tar"
5-
"bufio"
65
"bytes"
76
"errors"
87
"io"
98
"os"
109
"path/filepath"
11-
"regexp"
1210
"testing"
1311

14-
"github.com/Netflix/go-expect"
1512
"github.com/spf13/cobra"
1613
"github.com/stretchr/testify/require"
1714

@@ -21,12 +18,6 @@ import (
2118
"github.com/coder/coder/provisioner/echo"
2219
)
2320

24-
var (
25-
// Used to ensure terminal output doesn't have anything crazy!
26-
// See: https://stackoverflow.com/a/29497680
27-
stripAnsi = regexp.MustCompile("[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))")
28-
)
29-
3021
// New creates a CLI instance with a configuration pointed to a
3122
// temporary testing directory.
3223
func New(t *testing.T, args ...string) (*cobra.Command, config.Root) {
@@ -55,31 +46,6 @@ func CreateProjectVersionSource(t *testing.T, responses *echo.Responses) string
5546
return directory
5647
}
5748

58-
// NewConsole creates a new TTY bound to the command provided.
59-
// All ANSI escape codes are stripped to provide clean output.
60-
func NewConsole(t *testing.T, cmd *cobra.Command) *expect.Console {
61-
reader, writer := io.Pipe()
62-
scanner := bufio.NewScanner(reader)
63-
t.Cleanup(func() {
64-
_ = reader.Close()
65-
_ = writer.Close()
66-
})
67-
go func() {
68-
for scanner.Scan() {
69-
if scanner.Err() != nil {
70-
return
71-
}
72-
t.Log(stripAnsi.ReplaceAllString(scanner.Text(), ""))
73-
}
74-
}()
75-
76-
console, err := expect.NewConsole(expect.WithStdout(writer))
77-
require.NoError(t, err)
78-
cmd.SetIn(console.Tty())
79-
cmd.SetOut(console.Tty())
80-
return console
81-
}
82-
8349
func extractTar(t *testing.T, data []byte, directory string) {
8450
reader := tar.NewReader(bytes.NewBuffer(data))
8551
for {

cli/clitest/clitest_test.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
//go:build !windows
2-
31
package clitest_test
42

53
import (
64
"testing"
75

86
"github.com/coder/coder/cli/clitest"
97
"github.com/coder/coder/coderd/coderdtest"
8+
"github.com/coder/coder/expect"
109
"github.com/stretchr/testify/require"
1110
"go.uber.org/goleak"
1211
)
@@ -21,7 +20,7 @@ func TestCli(t *testing.T) {
2120
client := coderdtest.New(t)
2221
cmd, config := clitest.New(t)
2322
clitest.SetupConfig(t, client, config)
24-
console := clitest.NewConsole(t, cmd)
23+
console := expect.NewTestConsole(t, cmd)
2524
go func() {
2625
err := cmd.Execute()
2726
require.NoError(t, err)

cli/login.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ func login() *cobra.Command {
2222
Args: cobra.ExactArgs(1),
2323
RunE: func(cmd *cobra.Command, args []string) error {
2424
rawURL := args[0]
25+
2526
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
2627
scheme := "https"
2728
if strings.HasPrefix(rawURL, "localhost") {
@@ -44,7 +45,7 @@ func login() *cobra.Command {
4445
return xerrors.Errorf("has initial user: %w", err)
4546
}
4647
if !hasInitialUser {
47-
if !isTTY(cmd.InOrStdin()) {
48+
if !isTTY(cmd) {
4849
return xerrors.New("the initial user cannot be created in non-interactive mode. use the API")
4950
}
5051
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Your Coder deployment hasn't been set up!\n", color.HiBlackString(">"))

cli/login_test.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
//go:build !windows
2-
31
package cli_test
42

53
import (
64
"testing"
75

86
"github.com/coder/coder/cli/clitest"
7+
"github.com/coder/coder/expect"
98
"github.com/coder/coder/coderd/coderdtest"
109
"github.com/stretchr/testify/require"
1110
)
@@ -23,8 +22,11 @@ func TestLogin(t *testing.T) {
2322
t.Run("InitialUserTTY", func(t *testing.T) {
2423
t.Parallel()
2524
client := coderdtest.New(t)
26-
root, _ := clitest.New(t, "login", client.URL.String())
27-
console := clitest.NewConsole(t, root)
25+
// The --force-tty flag is required on Windows, because the `isatty` library does not
26+
// accurately detect Windows ptys when they are not attached to a process:
27+
// https://github.com/mattn/go-isatty/issues/59
28+
root, _ := clitest.New(t, "login", client.URL.String(), "--force-tty")
29+
console := expect.NewTestConsole(t, root)
2830
go func() {
2931
err := root.Execute()
3032
require.NoError(t, err)

cli/projectcreate_test.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
//go:build !windows
2-
31
package cli_test
42

53
import (
@@ -10,6 +8,7 @@ import (
108
"github.com/coder/coder/cli/clitest"
119
"github.com/coder/coder/coderd/coderdtest"
1210
"github.com/coder/coder/database"
11+
"github.com/coder/coder/expect"
1312
"github.com/coder/coder/provisioner/echo"
1413
"github.com/coder/coder/provisionersdk/proto"
1514
)
@@ -27,7 +26,7 @@ func TestProjectCreate(t *testing.T) {
2726
cmd, root := clitest.New(t, "projects", "create", "--directory", source, "--provisioner", string(database.ProvisionerTypeEcho))
2827
clitest.SetupConfig(t, client, root)
2928
_ = coderdtest.NewProvisionerDaemon(t, client)
30-
console := clitest.NewConsole(t, cmd)
29+
console := expect.NewTestConsole(t, cmd)
3130
closeChan := make(chan struct{})
3231
go func() {
3332
err := cmd.Execute()
@@ -74,7 +73,7 @@ func TestProjectCreate(t *testing.T) {
7473
cmd, root := clitest.New(t, "projects", "create", "--directory", source, "--provisioner", string(database.ProvisionerTypeEcho))
7574
clitest.SetupConfig(t, client, root)
7675
coderdtest.NewProvisionerDaemon(t, client)
77-
console := clitest.NewConsole(t, cmd)
76+
console := expect.NewTestConsole(t, cmd)
7877
closeChan := make(chan struct{})
7978
go func() {
8079
err := cmd.Execute()

cli/root.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121

2222
const (
2323
varGlobalConfig = "global-config"
24+
varForceTty = "force-tty"
2425
)
2526

2627
func Root() *cobra.Command {
@@ -65,6 +66,12 @@ func Root() *cobra.Command {
6566
cmd.AddCommand(users())
6667

6768
cmd.PersistentFlags().String(varGlobalConfig, configdir.LocalConfig("coder"), "Path to the global `coder` config directory")
69+
cmd.PersistentFlags().Bool(varForceTty, false, "Force the `coder` command to run as if connected to a TTY")
70+
err := cmd.PersistentFlags().MarkHidden(varForceTty)
71+
if err != nil {
72+
// This should never return an error, because we just added the `--force-tty`` flag prior to calling MarkHidden.
73+
panic(err)
74+
}
6875

6976
return cmd
7077
}
@@ -113,7 +120,16 @@ func createConfig(cmd *cobra.Command) config.Root {
113120
// isTTY returns whether the passed reader is a TTY or not.
114121
// This accepts a reader to work with Cobra's "InOrStdin"
115122
// function for simple testing.
116-
func isTTY(reader io.Reader) bool {
123+
func isTTY(cmd *cobra.Command) bool {
124+
// If the `--force-tty` command is available, and set,
125+
// assume we're in a tty. This is primarily for cases on Windows
126+
// where we may not be able to reliably detect this automatically (ie, tests)
127+
forceTty, err := cmd.Flags().GetBool(varForceTty)
128+
if forceTty && err == nil {
129+
return true
130+
}
131+
132+
reader := cmd.InOrStdin()
117133
file, ok := reader.(*os.File)
118134
if !ok {
119135
return false

cli/workspacecreate_test.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
//go:build !windows
2-
31
package cli_test
42

53
import (
64
"testing"
75

86
"github.com/coder/coder/cli/clitest"
97
"github.com/coder/coder/coderd/coderdtest"
8+
"github.com/coder/coder/expect"
109
"github.com/coder/coder/provisioner/echo"
1110
"github.com/coder/coder/provisionersdk/proto"
1211
"github.com/stretchr/testify/require"
@@ -37,7 +36,7 @@ func TestWorkspaceCreate(t *testing.T) {
3736
cmd, root := clitest.New(t, "workspaces", "create", project.Name)
3837
clitest.SetupConfig(t, client, root)
3938

40-
console := clitest.NewConsole(t, cmd)
39+
console := expect.NewTestConsole(t, cmd)
4140
closeChan := make(chan struct{})
4241
go func() {
4342
err := cmd.Execute()

expect/conpty/conpty.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
//go:build windows
2+
// +build windows
3+
4+
// Original copyright 2020 ActiveState Software. All rights reserved.
5+
// Use of this source code is governed by a BSD-style
6+
// license that can be found in the LICENSE file
7+
8+
package conpty
9+
10+
import (
11+
"fmt"
12+
"io"
13+
"os"
14+
15+
"golang.org/x/sys/windows"
16+
)
17+
18+
// ConPty represents a windows pseudo console.
19+
type ConPty struct {
20+
hpCon windows.Handle
21+
outPipePseudoConsoleSide windows.Handle
22+
outPipeOurSide windows.Handle
23+
inPipeOurSide windows.Handle
24+
inPipePseudoConsoleSide windows.Handle
25+
consoleSize uintptr
26+
outFilePseudoConsoleSide *os.File
27+
outFileOurSide *os.File
28+
inFilePseudoConsoleSide *os.File
29+
inFileOurSide *os.File
30+
closed bool
31+
}
32+
33+
// New returns a new ConPty pseudo terminal device
34+
func New(columns int16, rows int16) (*ConPty, error) {
35+
c := &ConPty{
36+
consoleSize: uintptr(columns) + (uintptr(rows) << 16),
37+
}
38+
39+
return c, c.createPseudoConsoleAndPipes()
40+
}
41+
42+
// Close closes the pseudo-terminal and cleans up all attached resources
43+
func (c *ConPty) Close() error {
44+
// Trying to close these pipes multiple times will result in an
45+
// access violation
46+
if c.closed {
47+
return nil
48+
}
49+
50+
err := closePseudoConsole(c.hpCon)
51+
c.outFilePseudoConsoleSide.Close()
52+
c.outFileOurSide.Close()
53+
c.inFilePseudoConsoleSide.Close()
54+
c.inFileOurSide.Close()
55+
c.closed = true
56+
return err
57+
}
58+
59+
// OutPipe returns the output pipe of the pseudo terminal
60+
func (c *ConPty) OutPipe() *os.File {
61+
return c.outFilePseudoConsoleSide
62+
}
63+
64+
func (c *ConPty) Reader() io.Reader {
65+
return c.outFileOurSide
66+
}
67+
68+
// InPipe returns input pipe of the pseudo terminal
69+
// Note: It is safer to use the Write method to prevent partially-written VT sequences
70+
// from corrupting the terminal
71+
func (c *ConPty) InPipe() *os.File {
72+
return c.inFilePseudoConsoleSide
73+
}
74+
75+
func (c *ConPty) WriteString(str string) (int, error) {
76+
return c.inFileOurSide.WriteString(str)
77+
}
78+
79+
func (c *ConPty) createPseudoConsoleAndPipes() error {
80+
// Create the stdin pipe
81+
if err := windows.CreatePipe(&c.inPipePseudoConsoleSide, &c.inPipeOurSide, nil, 0); err != nil {
82+
return err
83+
}
84+
85+
// Create the stdout pipe
86+
if err := windows.CreatePipe(&c.outPipeOurSide, &c.outPipePseudoConsoleSide, nil, 0); err != nil {
87+
return err
88+
}
89+
90+
// Create the pty with our stdin/stdout
91+
if err := createPseudoConsole(c.consoleSize, c.inPipePseudoConsoleSide, c.outPipePseudoConsoleSide, &c.hpCon); err != nil {
92+
return fmt.Errorf("failed to create pseudo console: %d, %v", uintptr(c.hpCon), err)
93+
}
94+
95+
c.outFilePseudoConsoleSide = os.NewFile(uintptr(c.outPipePseudoConsoleSide), "|0")
96+
c.outFileOurSide = os.NewFile(uintptr(c.outPipeOurSide), "|1")
97+
98+
c.inFilePseudoConsoleSide = os.NewFile(uintptr(c.inPipePseudoConsoleSide), "|2")
99+
c.inFileOurSide = os.NewFile(uintptr(c.inPipeOurSide), "|3")
100+
c.closed = false
101+
102+
return nil
103+
}
104+
105+
func (c *ConPty) Resize(cols uint16, rows uint16) error {
106+
return resizePseudoConsole(c.hpCon, uintptr(cols)+(uintptr(rows)<<16))
107+
}

expect/conpty/syscall.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//go:build windows
2+
// +build windows
3+
4+
// Copyright 2020 ActiveState Software. All rights reserved.
5+
// Use of this source code is governed by a BSD-style
6+
// license that can be found in the LICENSE file
7+
8+
package conpty
9+
10+
import (
11+
"unsafe"
12+
13+
"golang.org/x/sys/windows"
14+
)
15+
16+
var (
17+
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
18+
procResizePseudoConsole = kernel32.NewProc("ResizePseudoConsole")
19+
procCreatePseudoConsole = kernel32.NewProc("CreatePseudoConsole")
20+
procClosePseudoConsole = kernel32.NewProc("ClosePseudoConsole")
21+
)
22+
23+
func createPseudoConsole(consoleSize uintptr, ptyIn windows.Handle, ptyOut windows.Handle, hpCon *windows.Handle) (err error) {
24+
r1, _, e1 := procCreatePseudoConsole.Call(
25+
consoleSize,
26+
uintptr(ptyIn),
27+
uintptr(ptyOut),
28+
0,
29+
uintptr(unsafe.Pointer(hpCon)),
30+
)
31+
32+
if r1 != 0 { // !S_OK
33+
err = e1
34+
}
35+
return
36+
}
37+
38+
func resizePseudoConsole(handle windows.Handle, consoleSize uintptr) (err error) {
39+
r1, _, e1 := procResizePseudoConsole.Call(uintptr(handle), consoleSize)
40+
if r1 != 0 { // !S_OK
41+
err = e1
42+
}
43+
return
44+
}
45+
46+
func closePseudoConsole(handle windows.Handle) (err error) {
47+
r1, _, e1 := procClosePseudoConsole.Call(uintptr(handle))
48+
if r1 == 0 {
49+
err = e1
50+
}
51+
52+
return
53+
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy