Skip to content

Commit feefed0

Browse files
committed
Add support for STDOUT/STDERR expectations for Exec checker
this change adds stdout/stderr capturing to the Exec checker in order to verify the collected data against a regular expression. unit tests have been implemented to verify the implementation. closes #431
1 parent dd8df00 commit feefed0

6 files changed

Lines changed: 303 additions & 5 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,14 @@ Wait for a shell command to succeed or return a specific exit code.
401401
```bash
402402
wait4x exec 'ls target/debug/main' --exit-code 2
403403
```
404+
- **Check docker container status:**
405+
```bash
406+
wait4x exec 'docker inspect -f {{.State.Running}} my_container_name' --expect-stdout-regex "true"
407+
```
408+
- **Check lockfile is absent:**
409+
```bash
410+
wait4x exec 'ls /run/daemon/init.lock' --exit-code 2 --expect-stderr-regex "No such file or directory"
411+
```
404412

405413
---
406414

checker/exec/exec.go

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"fmt"
2121
"os/exec"
2222
"strings"
23+
"sync"
2324

2425
"wait4x.dev/v3/checker"
2526
)
@@ -29,9 +30,11 @@ type Option func(e *Exec)
2930

3031
// Exec is a command execution checker
3132
type Exec struct {
32-
command string
33-
args []string
34-
expectExitCode int
33+
command string
34+
args []string
35+
expectExitCode int
36+
expectStdoutRegex string
37+
expectStderrRegex string
3538
}
3639

3740
// New creates a new Exec checker
@@ -63,6 +66,22 @@ func WithExpectExitCode(code int) Option {
6366
}
6467
}
6568

69+
// WithExpectStdoutRegex configures the regular expression
70+
// which is evaluated against STDOUT.
71+
func WithExpectStdoutRegex(pattern string) Option {
72+
return func(e *Exec) {
73+
e.expectStdoutRegex = pattern
74+
}
75+
}
76+
77+
// WithExpectStderrRegex configures the regular expression
78+
// which is evaluated against STDERR.
79+
func WithExpectStderrRegex(pattern string) Option {
80+
return func(e *Exec) {
81+
e.expectStderrRegex = pattern
82+
}
83+
}
84+
6685
// Identity returns the identity of the checker
6786
func (e *Exec) Identity() (string, error) {
6887
if len(e.args) > 0 {
@@ -73,9 +92,60 @@ func (e *Exec) Identity() (string, error) {
7392

7493
// Check executes the command and checks if it returns the expected exit code
7594
func (e *Exec) Check(ctx context.Context) error {
95+
var stdout, stderr *pipeProcessor
96+
var errs chan error
97+
var err error
98+
7699
// Create command with context for cancellation support
77100
cmd := exec.CommandContext(ctx, e.command, e.args...)
78-
err := cmd.Run()
101+
102+
stdout, err = newPipeProcessor(e.expectStdoutRegex, cmd.StdoutPipe)
103+
if err != nil {
104+
return checker.NewExpectedError(
105+
"failed to prepare STDOUT processor", err,
106+
"command", e.command,
107+
"args", e.args,
108+
)
109+
}
110+
111+
stderr, err = newPipeProcessor(e.expectStderrRegex, cmd.StderrPipe)
112+
if err != nil {
113+
return checker.NewExpectedError(
114+
"failed to prepare STDERR processor", err,
115+
"command", e.command,
116+
"args", e.args,
117+
)
118+
}
119+
120+
if err := cmd.Start(); err != nil {
121+
return checker.NewExpectedError(
122+
"unable to start command", err,
123+
"command", e.command,
124+
"args", e.args,
125+
)
126+
}
127+
128+
if stdout != nil || stderr != nil {
129+
var wg sync.WaitGroup
130+
errs = make(chan error)
131+
132+
go func() {
133+
// wait for the workers to finish processing all of the data
134+
// before closing the channel, otherwise they will be blocked
135+
wg.Wait()
136+
close(errs)
137+
}()
138+
139+
if stdout != nil {
140+
stdout.Process(&wg, errs)
141+
}
142+
143+
if stderr != nil {
144+
stderr.Process(&wg, errs)
145+
}
146+
}
147+
148+
err = cmd.Wait()
79149

80150
// Check if context was canceled
81151
select {
@@ -112,5 +182,22 @@ func (e *Exec) Check(ctx context.Context) error {
112182
)
113183
}
114184

115-
return nil
185+
return drainErrors(errs)
186+
}
187+
188+
// drainErrors reads all items from the given channel
189+
// and returns the last non-nil value or nil if the
190+
// channel is nil or does not contain any errors.
191+
func drainErrors(errs chan error) (err error) {
192+
if errs == nil {
193+
return
194+
}
195+
196+
for e := range errs {
197+
if e != nil {
198+
err = e
199+
}
200+
}
201+
202+
return
116203
}

checker/exec/exec_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright 2019-2025 The Wait4X Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package exec provides the Exec checker for the Wait4X application.
16+
package exec
17+
18+
import (
19+
"context"
20+
"testing"
21+
22+
"github.com/stretchr/testify/assert"
23+
)
24+
25+
// TestCheck tests the Exec checker against various test scenarios.
26+
func TestCheck(t *testing.T) {
27+
const runner = "testdata/exec/runner.sh"
28+
29+
tests := map[string]struct {
30+
haveOptions []Option
31+
wantErr string
32+
}{
33+
"minimal": {},
34+
"unexpected exit code": {
35+
haveOptions: []Option{
36+
WithArgs([]string{"124"}), // instruct the runner to exit with code 124
37+
},
38+
wantErr: "command exited with unexpected code",
39+
},
40+
"expected exit code": {
41+
haveOptions: []Option{
42+
WithArgs([]string{"1"}),
43+
WithExpectExitCode(1),
44+
},
45+
},
46+
"stdout match": {
47+
haveOptions: []Option{
48+
WithExpectStdoutRegex("second line of stdout"),
49+
},
50+
},
51+
"stdout mismatch": {
52+
haveOptions: []Option{
53+
WithExpectStdoutRegex("stderr"),
54+
},
55+
wantErr: "no matching line found",
56+
},
57+
"stderr match": {
58+
haveOptions: []Option{
59+
WithExpectStderrRegex("second line of stderr"),
60+
},
61+
},
62+
"stderr mismatch": {
63+
haveOptions: []Option{
64+
WithExpectStderrRegex("stdout"),
65+
},
66+
wantErr: "no matching line found",
67+
},
68+
"both match": {
69+
haveOptions: []Option{
70+
WithExpectStdoutRegex("second line of stdout"),
71+
WithExpectStderrRegex("second line of stderr"),
72+
},
73+
},
74+
"both mismatch": {
75+
haveOptions: []Option{
76+
WithExpectStdoutRegex("stderr"),
77+
WithExpectStderrRegex("stdout"),
78+
},
79+
wantErr: "no matching line found",
80+
},
81+
}
82+
83+
for name, test := range tests {
84+
scenario := func(t *testing.T) {
85+
subject := New(runner, test.haveOptions...)
86+
gotErr := subject.Check(context.TODO())
87+
88+
if test.wantErr == "" {
89+
assert.Nil(t, gotErr)
90+
} else {
91+
assert.ErrorContains(t, gotErr, test.wantErr)
92+
}
93+
}
94+
95+
t.Run(name, scenario)
96+
}
97+
}

checker/exec/processor.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright 2019-2025 The Wait4X Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package exec provides the Exec checker for the Wait4X application.
16+
package exec
17+
18+
import (
19+
"bufio"
20+
"io"
21+
"regexp"
22+
"sync"
23+
24+
"wait4x.dev/v3/checker"
25+
)
26+
27+
type pipeProcessor struct {
28+
reader io.Reader
29+
pattern *regexp.Regexp
30+
}
31+
32+
func newPipeProcessor(pattern string, provider func() (io.ReadCloser, error)) (*pipeProcessor, error) {
33+
if pattern == "" {
34+
return nil, nil
35+
}
36+
37+
reg, err := regexp.Compile(pattern)
38+
if err != nil {
39+
return nil, err
40+
}
41+
42+
rdr, err := provider()
43+
if err != nil {
44+
return nil, err
45+
}
46+
47+
result := &pipeProcessor{
48+
reader: rdr,
49+
pattern: reg,
50+
}
51+
52+
return result, nil
53+
}
54+
55+
func (p *pipeProcessor) Process(wg *sync.WaitGroup, result chan<- error) {
56+
process := bufio.NewScanner(p.reader)
57+
worker := func(scanner *bufio.Scanner, pattern *regexp.Regexp) {
58+
defer wg.Done()
59+
60+
match := false
61+
for scanner.Scan() {
62+
if !match && pattern.Match(scanner.Bytes()) {
63+
match = true
64+
}
65+
}
66+
67+
if err := scanner.Err(); err != nil {
68+
result <- checker.NewExpectedError(
69+
"output scanner failed to process data", err,
70+
)
71+
}
72+
73+
if !match {
74+
result <- checker.NewExpectedError(
75+
"no matching line found", nil,
76+
"regexp", pattern.String(),
77+
)
78+
}
79+
}
80+
81+
wg.Add(1)
82+
go worker(process, p.pattern)
83+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#!/bin/sh -eu
2+
3+
cat <<STDOUT
4+
this is the first line of stdout
5+
this is the second line of stdout
6+
this is the third line of stdout
7+
this is the fourth line of stdout
8+
STDOUT
9+
10+
cat <<STDERR >&2
11+
this is the first line of stderr
12+
this is the second line of stderr
13+
this is the third line of stderr
14+
this is the fourth line of stderr
15+
STDERR
16+
17+
exit "${1:-0}"

internal/cmd/exec.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,17 @@ func NewExecCommand() *cobra.Command {
5252
}
5353

5454
execCommand.Flags().Int("exit-code", 0, "Expected exit code from the command")
55+
execCommand.Flags().String("expect-stdout-regex", "", "Expect command STDOUT pattern.")
56+
execCommand.Flags().String("expect-stderr-regex", "", "Expect command STDERR pattern.")
5557

5658
return execCommand
5759
}
5860

5961
// runExec runs the exec command
6062
func runExec(cmd *cobra.Command, args []string) error {
6163
exitCode, _ := cmd.Flags().GetInt("exit-code")
64+
expectStdoutRegex, _ := cmd.Flags().GetString("expect-stdout-regex")
65+
expectStderrRegex, _ := cmd.Flags().GetString("expect-stderr-regex")
6266

6367
logger, err := logr.FromContext(cmd.Context())
6468
if err != nil {
@@ -90,6 +94,8 @@ func runExec(cmd *cobra.Command, args []string) error {
9094
checker := exec.New(command,
9195
exec.WithArgs(commandArgs),
9296
exec.WithExpectExitCode(exitCode),
97+
exec.WithExpectStdoutRegex(expectStdoutRegex),
98+
exec.WithExpectStderrRegex(expectStderrRegex),
9399
)
94100

95101
return waiter.WaitContext(

0 commit comments

Comments
 (0)