Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commitc9c0312

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 versionThere 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 parent64c14de commitc9c0312

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
funcNew(t*testing.T,args...string) (*cobra.Command, config.Root) {
@@ -55,31 +46,6 @@ func CreateProjectVersionSource(t *testing.T, responses *echo.Responses) string
5546
returndirectory
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-
funcNewConsole(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-
gofunc() {
68-
forscanner.Scan() {
69-
ifscanner.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-
returnconsole
81-
}
82-
8349
funcextractTar(t*testing.T,data []byte,directorystring) {
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
gofunc() {
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
ifstrings.HasPrefix(rawURL,"localhost") {
@@ -44,7 +45,7 @@ func login() *cobra.Command {
4445
returnxerrors.Errorf("has initial user: %w",err)
4546
}
4647
if!hasInitialUser {
47-
if!isTTY(cmd.InOrStdin()) {
48+
if!isTTY(cmd) {
4849
returnxerrors.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
gofunc() {
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(chanstruct{})
3231
gofunc() {
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(chanstruct{})
7978
gofunc() {
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
funcRoot()*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+
iferr!=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
returncmd
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-
funcisTTY(reader io.Reader)bool {
123+
funcisTTY(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+
ifforceTty&&err==nil {
129+
returntrue
130+
}
131+
132+
reader:=cmd.InOrStdin()
117133
file,ok:=reader.(*os.File)
118134
if!ok {
119135
returnfalse

‎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(chanstruct{})
4241
gofunc() {
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+
typeConPtystruct {
20+
hpCon windows.Handle
21+
outPipePseudoConsoleSide windows.Handle
22+
outPipeOurSide windows.Handle
23+
inPipeOurSide windows.Handle
24+
inPipePseudoConsoleSide windows.Handle
25+
consoleSizeuintptr
26+
outFilePseudoConsoleSide*os.File
27+
outFileOurSide*os.File
28+
inFilePseudoConsoleSide*os.File
29+
inFileOurSide*os.File
30+
closedbool
31+
}
32+
33+
// New returns a new ConPty pseudo terminal device
34+
funcNew(columnsint16,rowsint16) (*ConPty,error) {
35+
c:=&ConPty{
36+
consoleSize:uintptr(columns)+ (uintptr(rows)<<16),
37+
}
38+
39+
returnc,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+
ifc.closed {
47+
returnnil
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+
returnerr
57+
}
58+
59+
// OutPipe returns the output pipe of the pseudo terminal
60+
func (c*ConPty)OutPipe()*os.File {
61+
returnc.outFilePseudoConsoleSide
62+
}
63+
64+
func (c*ConPty)Reader() io.Reader {
65+
returnc.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+
returnc.inFilePseudoConsoleSide
73+
}
74+
75+
func (c*ConPty)WriteString(strstring) (int,error) {
76+
returnc.inFileOurSide.WriteString(str)
77+
}
78+
79+
func (c*ConPty)createPseudoConsoleAndPipes()error {
80+
// Create the stdin pipe
81+
iferr:=windows.CreatePipe(&c.inPipePseudoConsoleSide,&c.inPipeOurSide,nil,0);err!=nil {
82+
returnerr
83+
}
84+
85+
// Create the stdout pipe
86+
iferr:=windows.CreatePipe(&c.outPipeOurSide,&c.outPipePseudoConsoleSide,nil,0);err!=nil {
87+
returnerr
88+
}
89+
90+
// Create the pty with our stdin/stdout
91+
iferr:=createPseudoConsole(c.consoleSize,c.inPipePseudoConsoleSide,c.outPipePseudoConsoleSide,&c.hpCon);err!=nil {
92+
returnfmt.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+
returnnil
103+
}
104+
105+
func (c*ConPty)Resize(colsuint16,rowsuint16)error {
106+
returnresizePseudoConsole(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+
funccreatePseudoConsole(consoleSizeuintptr,ptyIn windows.Handle,ptyOut windows.Handle,hpCon*windows.Handle) (errerror) {
24+
r1,_,e1:=procCreatePseudoConsole.Call(
25+
consoleSize,
26+
uintptr(ptyIn),
27+
uintptr(ptyOut),
28+
0,
29+
uintptr(unsafe.Pointer(hpCon)),
30+
)
31+
32+
ifr1!=0 {// !S_OK
33+
err=e1
34+
}
35+
return
36+
}
37+
38+
funcresizePseudoConsole(handle windows.Handle,consoleSizeuintptr) (errerror) {
39+
r1,_,e1:=procResizePseudoConsole.Call(uintptr(handle),consoleSize)
40+
ifr1!=0 {// !S_OK
41+
err=e1
42+
}
43+
return
44+
}
45+
46+
funcclosePseudoConsole(handle windows.Handle) (errerror) {
47+
r1,_,e1:=procClosePseudoConsole.Call(uintptr(handle))
48+
ifr1==0 {
49+
err=e1
50+
}
51+
52+
return
53+
}

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp