Skip to content

Commit db542bf

Browse files
committed
testscript: add waitmatch command
It blocks until a background process prints a matching line to stdout. Any given env var names are set to the subexpression strings as well. This is useful to wait for a background process to be ready before running more commands to interact with it, such as waiting for an HTTP server to listen on a TCP port before sending it the first request. In fact, our interrupt tests already had this problem; we only worked around it by having the process create a file when it's ready, and adding a custom command which would wait for the file by polling. It's much nicer and faster to use waitmatch instead, and as a bonus, we can refactor existing tests rather than adding more.
1 parent ccf4b43 commit db542bf

File tree

5 files changed

+159
-77
lines changed

5 files changed

+159
-77
lines changed

testscript/cmd.go

Lines changed: 105 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"bufio"
99
"bytes"
1010
"fmt"
11+
"io"
1112
"os"
1213
"os/exec"
1314
"path/filepath"
@@ -26,30 +27,31 @@ import (
2627
//
2728
// NOTE: If you make changes here, update doc.go.
2829
var scriptCmds = map[string]func(*TestScript, bool, []string){
29-
"cd": (*TestScript).cmdCd,
30-
"chmod": (*TestScript).cmdChmod,
31-
"cmp": (*TestScript).cmdCmp,
32-
"cmpenv": (*TestScript).cmdCmpenv,
33-
"cp": (*TestScript).cmdCp,
34-
"env": (*TestScript).cmdEnv,
35-
"exec": (*TestScript).cmdExec,
36-
"exists": (*TestScript).cmdExists,
37-
"grep": (*TestScript).cmdGrep,
38-
"kill": (*TestScript).cmdKill,
39-
"mkdir": (*TestScript).cmdMkdir,
40-
"mv": (*TestScript).cmdMv,
41-
"rm": (*TestScript).cmdRm,
42-
"skip": (*TestScript).cmdSkip,
43-
"stderr": (*TestScript).cmdStderr,
44-
"stdin": (*TestScript).cmdStdin,
45-
"stdout": (*TestScript).cmdStdout,
46-
"ttyin": (*TestScript).cmdTtyin,
47-
"ttyout": (*TestScript).cmdTtyout,
48-
"stop": (*TestScript).cmdStop,
49-
"symlink": (*TestScript).cmdSymlink,
50-
"unix2dos": (*TestScript).cmdUNIX2DOS,
51-
"unquote": (*TestScript).cmdUnquote,
52-
"wait": (*TestScript).cmdWait,
30+
"cd": (*TestScript).cmdCd,
31+
"chmod": (*TestScript).cmdChmod,
32+
"cmp": (*TestScript).cmdCmp,
33+
"cmpenv": (*TestScript).cmdCmpenv,
34+
"cp": (*TestScript).cmdCp,
35+
"env": (*TestScript).cmdEnv,
36+
"exec": (*TestScript).cmdExec,
37+
"exists": (*TestScript).cmdExists,
38+
"grep": (*TestScript).cmdGrep,
39+
"kill": (*TestScript).cmdKill,
40+
"mkdir": (*TestScript).cmdMkdir,
41+
"mv": (*TestScript).cmdMv,
42+
"rm": (*TestScript).cmdRm,
43+
"skip": (*TestScript).cmdSkip,
44+
"stderr": (*TestScript).cmdStderr,
45+
"stdin": (*TestScript).cmdStdin,
46+
"stdout": (*TestScript).cmdStdout,
47+
"ttyin": (*TestScript).cmdTtyin,
48+
"ttyout": (*TestScript).cmdTtyout,
49+
"stop": (*TestScript).cmdStop,
50+
"symlink": (*TestScript).cmdSymlink,
51+
"unix2dos": (*TestScript).cmdUNIX2DOS,
52+
"unquote": (*TestScript).cmdUnquote,
53+
"wait": (*TestScript).cmdWait,
54+
"waitmatch": (*TestScript).cmdWaitMatch,
5355
}
5456

5557
// cd changes to a different directory.
@@ -237,14 +239,26 @@ func (ts *TestScript) cmdExec(neg bool, args []string) {
237239
ts.Fatalf("duplicate background process name %q", bgName)
238240
}
239241
var cmd *exec.Cmd
240-
cmd, err = ts.execBackground(args[0], args[1:len(args)-1]...)
242+
var outreader io.ReadCloser
243+
cmd, outreader, err = ts.execBackground(args[0], args[1:len(args)-1]...)
241244
if err == nil {
242245
wait := make(chan struct{})
243246
go func() {
244247
waitOrStop(ts.ctxt, cmd, -1)
245248
close(wait)
246249
}()
247-
ts.background = append(ts.background, backgroundCmd{bgName, cmd, wait, neg})
250+
outbuf := new(strings.Builder)
251+
ts.background = append(ts.background, backgroundCmd{
252+
name: bgName,
253+
cmd: cmd,
254+
stdoutBuffer: outbuf,
255+
stdoutReader: struct {
256+
io.Reader
257+
io.Closer
258+
}{io.TeeReader(outreader, outbuf), outreader},
259+
waitc: wait,
260+
neg: neg,
261+
})
248262
}
249263
ts.stdout, ts.stderr = "", ""
250264
} else {
@@ -567,9 +581,7 @@ func (ts *TestScript) waitBackgroundOne(bgName string) {
567581
if bg == nil {
568582
ts.Fatalf("unknown background process %q", bgName)
569583
}
570-
<-bg.wait
571-
ts.stdout = bg.cmd.Stdout.(*strings.Builder).String()
572-
ts.stderr = bg.cmd.Stderr.(*strings.Builder).String()
584+
ts.stdout, ts.stderr = bg.wait()
573585
if ts.stdout != "" {
574586
fmt.Fprintf(&ts.log, "[stdout]\n%s", ts.stdout)
575587
}
@@ -614,18 +626,15 @@ func (ts *TestScript) findBackground(bgName string) *backgroundCmd {
614626
func (ts *TestScript) waitBackground(checkStatus bool) {
615627
var stdouts, stderrs []string
616628
for _, bg := range ts.background {
617-
<-bg.wait
629+
cmdStdout, cmdStderr := bg.wait()
618630

619631
args := append([]string{filepath.Base(bg.cmd.Args[0])}, bg.cmd.Args[1:]...)
620632
fmt.Fprintf(&ts.log, "[background] %s: %v\n", strings.Join(args, " "), bg.cmd.ProcessState)
621633

622-
cmdStdout := bg.cmd.Stdout.(*strings.Builder).String()
623634
if cmdStdout != "" {
624635
fmt.Fprintf(&ts.log, "[stdout]\n%s", cmdStdout)
625636
stdouts = append(stdouts, cmdStdout)
626637
}
627-
628-
cmdStderr := bg.cmd.Stderr.(*strings.Builder).String()
629638
if cmdStderr != "" {
630639
fmt.Fprintf(&ts.log, "[stderr]\n%s", cmdStderr)
631640
stderrs = append(stderrs, cmdStderr)
@@ -652,6 +661,69 @@ func (ts *TestScript) waitBackground(checkStatus bool) {
652661
ts.background = nil
653662
}
654663

664+
// cmdWaitMatch waits until a background command prints a line to standard output
665+
// which matches the given regular expression. Once a match is found, the given
666+
// environment variable names are set to the subexpressions of the match.
667+
func (ts *TestScript) cmdWaitMatch(neg bool, args []string) {
668+
if len(args) < 1 {
669+
ts.Fatalf("usage: waitmatch name regexp [env-var...]")
670+
}
671+
if neg {
672+
ts.Fatalf("unsupported: ! waitmatch")
673+
}
674+
bg := ts.findBackground(args[0])
675+
if bg == nil {
676+
ts.Fatalf("unknown background process %q", args[0])
677+
}
678+
rx, err := regexp.Compile(args[1])
679+
ts.Check(err)
680+
envs := args[2:]
681+
if n := rx.NumSubexp(); n < len(envs) {
682+
ts.Fatalf("cannot extract %d subexpressions into %d env vars", n, len(envs))
683+
}
684+
for {
685+
line, err := readLine(bg.stdoutReader)
686+
ts.Check(err)
687+
m := rx.FindSubmatch(line)
688+
if m != nil {
689+
subm := m[1:]
690+
for i, env := range envs {
691+
ts.Setenv(env, string(subm[i]))
692+
}
693+
}
694+
if err == io.EOF {
695+
if m == nil {
696+
ts.Fatalf("reached EOF without matching any line")
697+
}
698+
return
699+
} else {
700+
ts.Check(err)
701+
}
702+
if m != nil {
703+
break
704+
}
705+
}
706+
}
707+
708+
// readLine consumes enough bytes to read a line.
709+
func readLine(r io.Reader) ([]byte, error) {
710+
var line []byte
711+
for {
712+
var buf [1]byte
713+
n, err := r.Read(buf[:])
714+
if n > 0 {
715+
b := buf[0]
716+
if b == '\n' {
717+
return line, nil
718+
}
719+
line = append(line, b)
720+
}
721+
if err != nil {
722+
return line, err
723+
}
724+
}
725+
}
726+
655727
// scriptMatch implements both stdout and stderr.
656728
func scriptMatch(ts *TestScript, neg bool, args []string, text, name string) {
657729
n := 0

testscript/testdata/interrupt.txt

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
11
[windows] skip
22

3-
signalcatcher &
4-
waitfile catchsignal
3+
# Start a background process, wait for it to be ready by printing a line,
4+
# send it an interrupt to stop, and wait for it to stop.
5+
6+
signalcatcher &sigc&
7+
waitmatch sigc '^Ready to catch signals; the magic word is (.*)$' MAGIC_WORD
8+
printargs ${MAGIC_WORD}
9+
cmp stdout args.want
10+
511
interrupt
612
wait
7-
stdout 'caught interrupt'
13+
# Make sure the entire stdout still contains what waitmatch read.
14+
cmp stdout stdout.want
15+
16+
-- args.want --
17+
["printargs" "Huzzah!"]
18+
-- stdout.want --
19+
Ready to catch signals; the magic word is Huzzah!
20+
caught interrupt
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# Let testscript stop signalcatcher at the end of the testscript.
22

3-
signalcatcher &
4-
waitfile catchsignal
3+
signalcatcher &sigc&
4+
waitmatch sigc '^Ready to catch signals'

testscript/testscript.go

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -418,10 +418,27 @@ type TestScript struct {
418418
}
419419

420420
type backgroundCmd struct {
421-
name string
422-
cmd *exec.Cmd
423-
wait <-chan struct{}
424-
neg bool // if true, cmd should fail
421+
name string
422+
cmd *exec.Cmd
423+
stdoutReader io.ReadCloser // a stdout pipe for waitmatch to read lines
424+
stdoutBuffer *strings.Builder // all reads from stdoutReader are buffered here
425+
test io.ReadCloser
426+
waitc <-chan struct{}
427+
neg bool // if true, cmd should fail
428+
}
429+
430+
func (b *backgroundCmd) wait() (stdout, stderr string) {
431+
// Consume the rest of stdoutReader to fill stdoutBuffer.
432+
io.Copy(io.Discard, b.stdoutReader)
433+
b.stdoutReader.Close()
434+
<-b.waitc
435+
stdout = b.stdoutBuffer.String()
436+
stderr = b.cmd.Stderr.(*strings.Builder).String()
437+
return stdout, stderr
438+
}
439+
440+
func (b *backgroundCmd) stderr() string {
441+
return b.cmd.Stderr.(*strings.Builder).String()
425442
}
426443

427444
func writeFile(name string, data []byte, perm fs.FileMode, excl bool) error {
@@ -575,7 +592,7 @@ func (ts *TestScript) run() {
575592
ts.waitBackground(false)
576593
} else {
577594
for _, bg := range ts.background {
578-
<-bg.wait
595+
bg.wait()
579596
}
580597
ts.background = nil
581598
}
@@ -1027,22 +1044,26 @@ func (ts *TestScript) exec(command string, args ...string) (stdout, stderr strin
10271044

10281045
// execBackground starts the given command line (an actual subprocess, not simulated)
10291046
// in ts.cd with environment ts.env.
1030-
func (ts *TestScript) execBackground(command string, args ...string) (*exec.Cmd, error) {
1047+
func (ts *TestScript) execBackground(command string, args ...string) (*exec.Cmd, io.ReadCloser, error) {
10311048
if ts.ttyin != "" {
1032-
return nil, errors.New("ttyin is not supported by background commands")
1049+
return nil, nil, errors.New("ttyin is not supported by background commands")
10331050
}
10341051
cmd, err := ts.buildExecCmd(command, args...)
10351052
if err != nil {
1036-
return nil, err
1053+
return nil, nil, err
10371054
}
10381055
cmd.Dir = ts.cd
10391056
cmd.Env = append(ts.env, "PWD="+ts.cd)
1040-
var stdoutBuf, stderrBuf strings.Builder
1057+
var stderrBuf strings.Builder
10411058
cmd.Stdin = strings.NewReader(ts.stdin)
1042-
cmd.Stdout = &stdoutBuf
1059+
stdoutr, stdoutw, err := os.Pipe()
1060+
if err != nil {
1061+
return nil, nil, err
1062+
}
1063+
cmd.Stdout = stdoutw
10431064
cmd.Stderr = &stderrBuf
10441065
ts.stdin = ""
1045-
return cmd, cmd.Start()
1066+
return cmd, stdoutr, cmd.Start()
10461067
}
10471068

10481069
func (ts *TestScript) buildExecCmd(command string, args ...string) (*exec.Cmd, error) {
@@ -1143,6 +1164,9 @@ func waitOrStop(ctx context.Context, cmd *exec.Cmd, killDelay time.Duration) err
11431164
}()
11441165

11451166
waitErr := cmd.Wait()
1167+
if f, ok := cmd.Stdout.(*os.File); ok {
1168+
f.Close()
1169+
}
11461170
if interruptErr := <-errc; interruptErr != nil {
11471171
return interruptErr
11481172
}

testscript/testscript_test.go

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,7 @@ func signalCatcher() int {
4646
// Note: won't work under Windows.
4747
c := make(chan os.Signal, 1)
4848
signal.Notify(c, os.Interrupt)
49-
// Create a file so that the test can know that
50-
// we will catch the signal.
51-
if err := os.WriteFile("catchsignal", nil, 0o666); err != nil {
52-
fmt.Println(err)
53-
return 1
54-
}
49+
fmt.Println("Ready to catch signals; the magic word is Huzzah!")
5550
<-c
5651
fmt.Println("caught interrupt")
5752
return 0
@@ -180,7 +175,6 @@ func TestScripts(t *testing.T) {
180175
"setSpecialVal": setSpecialVal,
181176
"ensureSpecialVal": ensureSpecialVal,
182177
"interrupt": interrupt,
183-
"waitfile": waitFile,
184178
"testdefer": func(ts *TestScript, neg bool, args []string) {
185179
testDeferCount++
186180
n := testDeferCount
@@ -462,27 +456,6 @@ func interrupt(ts *TestScript, neg bool, args []string) {
462456
bg[0].Process.Signal(os.Interrupt)
463457
}
464458

465-
func waitFile(ts *TestScript, neg bool, args []string) {
466-
if neg {
467-
ts.Fatalf("waitfile does not support neg")
468-
}
469-
if len(args) != 1 {
470-
ts.Fatalf("usage: waitfile file")
471-
}
472-
path := ts.MkAbs(args[0])
473-
for i := 0; i < 100; i++ {
474-
_, err := os.Stat(path)
475-
if err == nil {
476-
return
477-
}
478-
if !os.IsNotExist(err) {
479-
ts.Fatalf("unexpected stat error: %v", err)
480-
}
481-
time.Sleep(10 * time.Millisecond)
482-
}
483-
ts.Fatalf("timed out waiting for %q to be created", path)
484-
}
485-
486459
type fakeT struct {
487460
log strings.Builder
488461
verbose bool

0 commit comments

Comments
 (0)