Skip to content

Commit e748a67

Browse files
FiloSottilemvdan
authored andcommitted
testscript: add ttyin/ttyout commands
1 parent ec11942 commit e748a67

File tree

11 files changed

+270
-18
lines changed

11 files changed

+270
-18
lines changed

testscript/cmd.go

+42
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ import (
1313
"os/exec"
1414
"path/filepath"
1515
"regexp"
16+
"runtime"
1617
"strconv"
1718
"strings"
1819

1920
"github.com/rogpeppe/go-internal/diff"
21+
"github.com/rogpeppe/go-internal/testscript/internal/pty"
2022
"github.com/rogpeppe/go-internal/txtar"
2123
)
2224

@@ -41,6 +43,8 @@ var scriptCmds = map[string]func(*TestScript, bool, []string){
4143
"stderr": (*TestScript).cmdStderr,
4244
"stdin": (*TestScript).cmdStdin,
4345
"stdout": (*TestScript).cmdStdout,
46+
"ttyin": (*TestScript).cmdTtyin,
47+
"ttyout": (*TestScript).cmdTtyout,
4448
"stop": (*TestScript).cmdStop,
4549
"symlink": (*TestScript).cmdSymlink,
4650
"unix2dos": (*TestScript).cmdUNIX2DOS,
@@ -178,6 +182,10 @@ func (ts *TestScript) cmdCp(neg bool, args []string) {
178182
src = arg
179183
data = []byte(ts.stderr)
180184
mode = 0o666
185+
case "ttyout":
186+
src = arg
187+
data = []byte(ts.ttyout)
188+
mode = 0o666
181189
default:
182190
src = ts.MkAbs(arg)
183191
info, err := os.Stat(src)
@@ -382,6 +390,9 @@ func (ts *TestScript) cmdStdin(neg bool, args []string) {
382390
if len(args) != 1 {
383391
ts.Fatalf("usage: stdin filename")
384392
}
393+
if ts.stdinPty {
394+
ts.Fatalf("conflicting use of 'stdin' and 'ttyin -stdin'")
395+
}
385396
ts.stdin = ts.ReadFile(args[0])
386397
}
387398

@@ -401,6 +412,37 @@ func (ts *TestScript) cmdGrep(neg bool, args []string) {
401412
scriptMatch(ts, neg, args, "", "grep")
402413
}
403414

415+
func (ts *TestScript) cmdTtyin(neg bool, args []string) {
416+
if !pty.Supported {
417+
ts.Fatalf("unsupported: ttyin on %s", runtime.GOOS)
418+
}
419+
if neg {
420+
ts.Fatalf("unsupported: ! ttyin")
421+
}
422+
switch len(args) {
423+
case 1:
424+
ts.ttyin = ts.ReadFile(args[0])
425+
case 2:
426+
if args[0] != "-stdin" {
427+
ts.Fatalf("usage: ttyin [-stdin] filename")
428+
}
429+
if ts.stdin != "" {
430+
ts.Fatalf("conflicting use of 'stdin' and 'ttyin -stdin'")
431+
}
432+
ts.stdinPty = true
433+
ts.ttyin = ts.ReadFile(args[1])
434+
default:
435+
ts.Fatalf("usage: ttyin [-stdin] filename")
436+
}
437+
if ts.ttyin == "" {
438+
ts.Fatalf("tty input file is empty")
439+
}
440+
}
441+
442+
func (ts *TestScript) cmdTtyout(neg bool, args []string) {
443+
scriptMatch(ts, neg, args, ts.ttyout, "ttyout")
444+
}
445+
404446
// stop stops execution of the test (marking it passed).
405447
func (ts *TestScript) cmdStop(neg bool, args []string) {
406448
if neg {

testscript/doc.go

+10
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,16 @@ The predefined commands are:
205205
Apply the grep command (see above) to the standard output
206206
from the most recent exec or wait command.
207207
208+
- ttyin [-stdin] file
209+
Attach the next exec command to a controlling pseudo-terminal, and use the
210+
contents of the given file as the raw terminal input. If -stdin is specified,
211+
also attach the terminal to standard input.
212+
Note that this does not attach the terminal to standard output/error.
213+
214+
- [!] ttyout [-count=N] pattern
215+
Apply the grep command (see above) to the raw controlling terminal output
216+
from the most recent exec command.
217+
208218
- stop [message]
209219
Stop the test early (marking it as passing), including the message if given.
210220

testscript/envvarname.go

-7
This file was deleted.

testscript/envvarname_windows.go

-7
This file was deleted.

testscript/internal/pty/pty.go

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
//go:build linux || darwin
2+
// +build linux darwin
3+
4+
package pty
5+
6+
import (
7+
"fmt"
8+
"os"
9+
"os/exec"
10+
"syscall"
11+
)
12+
13+
const Supported = true
14+
15+
func SetCtty(cmd *exec.Cmd, tty *os.File) {
16+
cmd.SysProcAttr = &syscall.SysProcAttr{
17+
Setctty: true,
18+
Setsid: true,
19+
Ctty: 3,
20+
}
21+
cmd.ExtraFiles = []*os.File{tty}
22+
}
23+
24+
func Open() (pty, tty *os.File, err error) {
25+
p, err := os.OpenFile("/dev/ptmx", os.O_RDWR, 0)
26+
if err != nil {
27+
return nil, nil, fmt.Errorf("failed to open pty multiplexer: %v", err)
28+
}
29+
defer func() {
30+
if err != nil {
31+
p.Close()
32+
}
33+
}()
34+
35+
name, err := ptyName(p)
36+
if err != nil {
37+
return nil, nil, fmt.Errorf("failed to obtain tty name: %v", err)
38+
}
39+
40+
if err := ptyGrant(p); err != nil {
41+
return nil, nil, fmt.Errorf("failed to grant pty: %v", err)
42+
}
43+
44+
if err := ptyUnlock(p); err != nil {
45+
return nil, nil, fmt.Errorf("failed to unlock pty: %v", err)
46+
}
47+
48+
t, err := os.OpenFile(name, os.O_RDWR|syscall.O_NOCTTY, 0)
49+
if err != nil {
50+
return nil, nil, fmt.Errorf("failed to open TTY: %v", err)
51+
}
52+
53+
return p, t, nil
54+
}
55+
56+
func ioctl(f *os.File, name string, cmd, ptr uintptr) error {
57+
_, _, err := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), cmd, ptr)
58+
if err != 0 {
59+
return fmt.Errorf("%s ioctl failed: %v", name, err)
60+
}
61+
return nil
62+
}

testscript/internal/pty/pty_darwin.go

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package pty
2+
3+
import (
4+
"bytes"
5+
"os"
6+
"syscall"
7+
"unsafe"
8+
)
9+
10+
func ptyName(f *os.File) (string, error) {
11+
// Parameter length is encoded in the low 13 bits of the top word.
12+
// See https://github.com/apple/darwin-xnu/blob/2ff845c2e0/bsd/sys/ioccom.h#L69-L77
13+
const IOCPARM_MASK = 0x1fff
14+
const TIOCPTYGNAME_PARM_LEN = (syscall.TIOCPTYGNAME >> 16) & IOCPARM_MASK
15+
out := make([]byte, TIOCPTYGNAME_PARM_LEN)
16+
17+
err := ioctl(f, "TIOCPTYGNAME", syscall.TIOCPTYGNAME, uintptr(unsafe.Pointer(&out[0])))
18+
if err != nil {
19+
return "", err
20+
}
21+
22+
i := bytes.IndexByte(out, 0x00)
23+
return string(out[:i]), nil
24+
}
25+
26+
func ptyGrant(f *os.File) error {
27+
return ioctl(f, "TIOCPTYGRANT", syscall.TIOCPTYGRANT, 0)
28+
}
29+
30+
func ptyUnlock(f *os.File) error {
31+
return ioctl(f, "TIOCPTYUNLK", syscall.TIOCPTYUNLK, 0)
32+
}

testscript/internal/pty/pty_linux.go

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package pty
2+
3+
import (
4+
"os"
5+
"strconv"
6+
"syscall"
7+
"unsafe"
8+
)
9+
10+
func ptyName(f *os.File) (string, error) {
11+
var out uint
12+
err := ioctl(f, "TIOCGPTN", syscall.TIOCGPTN, uintptr(unsafe.Pointer(&out)))
13+
if err != nil {
14+
return "", err
15+
}
16+
return "/dev/pts/" + strconv.Itoa(int(out)), nil
17+
}
18+
19+
func ptyGrant(f *os.File) error {
20+
return nil
21+
}
22+
23+
func ptyUnlock(f *os.File) error {
24+
var zero int
25+
return ioctl(f, "TIOCSPTLCK", syscall.TIOCSPTLCK, uintptr(unsafe.Pointer(&zero)))
26+
}
+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//go:build !linux && !darwin
2+
// +build !linux,!darwin
3+
4+
package pty
5+
6+
import (
7+
"fmt"
8+
"os"
9+
"os/exec"
10+
"runtime"
11+
)
12+
13+
const Supported = false
14+
15+
func SetCtty(cmd *exec.Cmd, tty *os.File) error {
16+
panic("SetCtty called on unsupported platform")
17+
}
18+
19+
func Open() (pty, tty *os.File, err error) {
20+
return nil, nil, fmt.Errorf("pty unsupported on %s", runtime.GOOS)
21+
}

testscript/testdata/pty.txt

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[!linux] [!darwin] skip
2+
3+
ttyin secretwords.txt
4+
terminalprompt
5+
ttyout 'magic words'
6+
! stderr .
7+
! stdout .
8+
9+
-- secretwords.txt --
10+
SQUEAMISHOSSIFRAGE

testscript/testscript.go

+46
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import (
3434
"github.com/rogpeppe/go-internal/internal/os/execpath"
3535
"github.com/rogpeppe/go-internal/par"
3636
"github.com/rogpeppe/go-internal/testenv"
37+
"github.com/rogpeppe/go-internal/testscript/internal/pty"
3738
"github.com/rogpeppe/go-internal/txtar"
3839
)
3940

@@ -100,6 +101,13 @@ func (e *Env) Getenv(key string) string {
100101
return ""
101102
}
102103

104+
func envvarname(k string) string {
105+
if runtime.GOOS == "windows" {
106+
return strings.ToLower(k)
107+
}
108+
return k
109+
}
110+
103111
// Setenv sets the value of the environment variable named by the key. It
104112
// panics if key is invalid.
105113
func (e *Env) Setenv(key, value string) {
@@ -357,6 +365,9 @@ type TestScript struct {
357365
stdin string // standard input to next 'go' command; set by 'stdin' command.
358366
stdout string // standard output from last 'go' command; for 'stdout' command
359367
stderr string // standard error from last 'go' command; for 'stderr' command
368+
ttyin string // terminal input; set by 'ttyin' command
369+
stdinPty bool // connect pty to standard input; set by 'ttyin -stdin' command
370+
ttyout string // terminal output; for 'ttyout' command
360371
stopped bool // test wants to stop early
361372
start time.Time // time phase started
362373
background []backgroundCmd // backgrounded 'exec' and 'go' commands
@@ -940,16 +951,49 @@ func (ts *TestScript) exec(command string, args ...string) (stdout, stderr strin
940951
var stdoutBuf, stderrBuf strings.Builder
941952
cmd.Stdout = &stdoutBuf
942953
cmd.Stderr = &stderrBuf
954+
if ts.ttyin != "" {
955+
ctrl, tty, err := pty.Open()
956+
if err != nil {
957+
return "", "", err
958+
}
959+
doneR, doneW := make(chan struct{}), make(chan struct{})
960+
var ptyBuf strings.Builder
961+
go func() {
962+
io.Copy(ctrl, strings.NewReader(ts.ttyin))
963+
ctrl.Write([]byte{4 /* EOT */})
964+
close(doneW)
965+
}()
966+
go func() {
967+
io.Copy(&ptyBuf, ctrl)
968+
close(doneR)
969+
}()
970+
defer func() {
971+
tty.Close()
972+
ctrl.Close()
973+
<-doneR
974+
<-doneW
975+
ts.ttyin = ""
976+
ts.ttyout = ptyBuf.String()
977+
}()
978+
pty.SetCtty(cmd, tty)
979+
if ts.stdinPty {
980+
cmd.Stdin = tty
981+
}
982+
}
943983
if err = cmd.Start(); err == nil {
944984
err = waitOrStop(ts.ctxt, cmd, ts.gracePeriod)
945985
}
946986
ts.stdin = ""
987+
ts.stdinPty = false
947988
return stdoutBuf.String(), stderrBuf.String(), err
948989
}
949990

950991
// execBackground starts the given command line (an actual subprocess, not simulated)
951992
// in ts.cd with environment ts.env.
952993
func (ts *TestScript) execBackground(command string, args ...string) (*exec.Cmd, error) {
994+
if ts.ttyin != "" {
995+
return nil, errors.New("ttyin is not supported by background commands")
996+
}
953997
cmd, err := ts.buildExecCmd(command, args...)
954998
if err != nil {
955999
return nil, err
@@ -1126,6 +1170,8 @@ func (ts *TestScript) ReadFile(file string) string {
11261170
return ts.stdout
11271171
case "stderr":
11281172
return ts.stderr
1173+
case "ttyout":
1174+
return ts.ttyout
11291175
default:
11301176
file = ts.MkAbs(file)
11311177
data, err := ioutil.ReadFile(file)

testscript/testscript_test.go

+21-4
Original file line numberDiff line numberDiff line change
@@ -58,17 +58,34 @@ func signalCatcher() int {
5858
return 0
5959
}
6060

61+
func terminalPrompt() int {
62+
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
63+
if err != nil {
64+
fmt.Println(err)
65+
return 1
66+
}
67+
tty.WriteString("The magic words are: ")
68+
var words string
69+
fmt.Fscanln(tty, &words)
70+
if words != "SQUEAMISHOSSIFRAGE" {
71+
fmt.Println(words)
72+
return 42
73+
}
74+
return 0
75+
}
76+
6177
func TestMain(m *testing.M) {
6278
timeSince = func(t time.Time) time.Duration {
6379
return 0
6480
}
6581

6682
showVerboseEnv = false
6783
os.Exit(RunMain(m, map[string]func() int{
68-
"printargs": printArgs,
69-
"fprintargs": fprintArgs,
70-
"status": exitWithStatus,
71-
"signalcatcher": signalCatcher,
84+
"printargs": printArgs,
85+
"fprintargs": fprintArgs,
86+
"status": exitWithStatus,
87+
"signalcatcher": signalCatcher,
88+
"terminalprompt": terminalPrompt,
7289
}))
7390
}
7491

0 commit comments

Comments
 (0)