Skip to content

Commit ab1c45c

Browse files
committed
Merge branch 'testexe-more'
2 parents ce08d45 + 14eb3cb commit ab1c45c

11 files changed

Lines changed: 207 additions & 17 deletions

File tree

internal/testexe/capture.go

Lines changed: 109 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package testexe
1818

1919
import (
2020
"bytes"
21+
"errors"
2122
"fmt"
2223
"io"
2324
"os"
@@ -30,9 +31,48 @@ import (
3031
"testing"
3132
)
3233

33-
// Capture runs the given command and captures its stdin, stdout, stderr and exit status.
34-
// The command's Args field is expected to be set to the full command line, with Args[0] being the executable name.
34+
// CaptureResult represents the result of a captured command execution for regression testing.
35+
// It includes the command line arguments, environment variables, stdin, stdout, stderr and exit status.
36+
//
37+
// The representation format on disk is inspired by [txtar]:
38+
//
39+
// command arg1 arg2
40+
//
41+
// -- env --
42+
// ENV_VAR=value
43+
// -- stdin --
44+
// stdin content
45+
// -- exit status: N --
46+
// -- stdout --
47+
// stdout content
48+
// -- stderr --
49+
// stderr content
50+
//
51+
// All sections are optional except the command line.
52+
//
53+
// In the stdin, stdout and stderr sections, if the content does not end with a newline,
54+
// a "^D" marker is appended to indicate the end of the content.
55+
//
56+
// [txtar]: https://pkg.go.dev/golang.org/x/tools/txtar
57+
type CaptureResult struct {
58+
Args []string
59+
Env []string
60+
Stdin string
61+
62+
ExitStatus int
63+
Stdout string
64+
Stderr string
65+
}
66+
67+
// Capture runs the given command and captures its stdin, stdout,
68+
// stderr and exit status.
69+
//
70+
// The command's Args field is expected to be set to the full command
71+
// line, with Args[0] being the executable name.
3572
// The command's Stdin may point to a reader that will also be captured.
73+
// The command's Env may be set to a custom environment, which will be
74+
// captured as well but only the differences with the system environment
75+
// are recorded).
3676
func Capture(cmd *exec.Cmd) (*CaptureResult, error) {
3777
var stdin, stdout, stderr bytes.Buffer
3878
var withStdin bool
@@ -75,17 +115,39 @@ func Capture(cmd *exec.Cmd) (*CaptureResult, error) {
75115
cap.Stdout = stdout.String()
76116
cap.Stderr = stderr.String()
77117

78-
return &cap, nil
79-
}
80-
81-
// CaptureResult represents the result of a captured command execution for regression testing.
82-
type CaptureResult struct {
83-
Args []string
84-
Stdin string
118+
sysEnv := make(map[string]string)
119+
for _, e := range os.Environ() {
120+
k, v, ok := strings.Cut(e, "=")
121+
if !ok {
122+
continue
123+
}
124+
sysEnv[k] = v
125+
}
126+
// Record only the differences between the command's environment and the system environment
127+
for _, e := range cmd.Env {
128+
k, v, ok := strings.Cut(e, "=")
129+
if !ok || v == sysEnv[k] {
130+
continue
131+
}
132+
if runtime.GOOS == "windows" && k == "SYSTEMROOT" {
133+
// Ignore SYSTEMROOT, which is always set on Windows and may differ between test runs.
134+
continue
135+
}
136+
override := false
137+
for i, e2 := range cap.Env {
138+
if strings.HasPrefix(e2, k+"=") {
139+
cap.Env[i] = e
140+
override = true
141+
break
142+
}
143+
}
144+
if !override {
145+
cap.Env = append(cap.Env, e)
146+
}
147+
}
148+
slices.Sort(cap.Env)
85149

86-
ExitStatus int
87-
Stdout string
88-
Stderr string
150+
return &cap, nil
89151
}
90152

91153
func writeStream(w io.Writer, title string, content string) (n int64, err error) {
@@ -154,6 +216,21 @@ func (r *CaptureResult) WriteTo(output io.Writer) (n int64, err error) {
154216
if err != nil {
155217
return
156218
}
219+
if len(r.Env) > 0 {
220+
nn, err = fmt.Fprintln(output, "-- env --")
221+
n += int64(nn)
222+
if err != nil {
223+
return
224+
}
225+
slices.Sort(r.Env)
226+
for _, e := range r.Env {
227+
nn, err = fmt.Fprintln(output, e)
228+
n += int64(nn)
229+
if err != nil {
230+
return
231+
}
232+
}
233+
}
157234
nnn, err := writeStream(output, "stdin", r.Stdin)
158235
n += nnn
159236
if err != nil {
@@ -253,6 +330,15 @@ func ParseCapture(r io.Reader) (*CaptureResult, error) {
253330
case "-- stderr --":
254331
result.Stderr, lines = readStream(lines[i+1:])
255332
i = 0
333+
case "-- env --":
334+
var env []string
335+
i++
336+
for i < len(lines) && !strings.HasPrefix(lines[i], "-- ") {
337+
env = append(env, lines[i])
338+
i++
339+
}
340+
slices.Sort(env)
341+
result.Env = env
256342
default:
257343
const prefix = "-- exit status: "
258344
if strings.HasPrefix(lines[i], prefix) {
@@ -274,6 +360,12 @@ func ParseCapture(r io.Reader) (*CaptureResult, error) {
274360
// captured result matches the expectation.
275361
// cmd.Args and cmd.Stdin are ignored and replaced by expected.Args and expected.Stdin for the execution.
276362
func CommandAssert(cmd *exec.Cmd, expected *CaptureResult) error {
363+
if len(expected.Env) > 0 {
364+
if cmd.Env == nil {
365+
cmd.Env = os.Environ()
366+
}
367+
cmd.Env = append(cmd.Env, expected.Env...)
368+
}
277369
cmd.Stdin = strings.NewReader(expected.Stdin)
278370
cmd.Args = append(append(make([]string, 0, len(expected.Args)), cmd.Args[0]), expected.Args[1:]...)
279371

@@ -283,7 +375,11 @@ func CommandAssert(cmd *exec.Cmd, expected *CaptureResult) error {
283375
}
284376

285377
if result.ExitStatus != expected.ExitStatus {
286-
return fmt.Errorf("unexpected exit status: got %d, expected %d", result.ExitStatus, expected.ExitStatus)
378+
err := fmt.Errorf("unexpected exit status: got %d, expected %d", result.ExitStatus, expected.ExitStatus)
379+
if result.Stderr != expected.Stderr {
380+
err = errors.Join(err, fmt.Errorf("%w\nunexpected stderr: got %q, expected %q", err, result.Stderr, expected.Stderr))
381+
}
382+
return err
287383
}
288384
if result.Stderr != expected.Stderr {
289385
return fmt.Errorf("unexpected stderr: got %q, expected %q", result.Stderr, expected.Stderr)

internal/testexe/capture_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ package testexe_test
1818

1919
import (
2020
"bytes"
21+
"os"
2122
"path/filepath"
23+
"strings"
2224
"testing"
2325

2426
"github.com/dolmen-go/goeval/internal/testexe"
@@ -65,3 +67,59 @@ func TestWriteCaptureExit(t *testing.T) {
6567
t.Parallel()
6668
echo.TestWriteCapture(t, "echo/testdata/echo_exit42.golden", "-exit=42", "-stderr=Exit 42")
6769
}
70+
71+
func TestCaptureEnv(t *testing.T) {
72+
t.Parallel()
73+
74+
const (
75+
envVar1 = "TEST_CAPTURE_ENV_VAR1"
76+
envVar2 = "TEST_CAPTURE_ENV_VAR2"
77+
envValue = "42"
78+
)
79+
80+
goldenPath := filepath.Clean(filepath.Join(t.TempDir(), t.Name()+".golden"))
81+
82+
cmdArgs := []string{"-stdout=hello"}
83+
cmd := echo.TestCommand(t, cmdArgs...)
84+
85+
// Append env vars in reverse order to check that they are sorted in the capture result.
86+
cmd.Env = append(os.Environ(), envVar2+"="+envValue, envVar1+"="+envValue)
87+
88+
err := testexe.WriteCapture(cmd, goldenPath)
89+
if err != nil {
90+
t.Fatal(err)
91+
}
92+
defer os.Remove(goldenPath)
93+
94+
content, err := os.ReadFile(goldenPath)
95+
if err != nil {
96+
t.Fatal(err)
97+
}
98+
t.Log("\n" + string(content))
99+
cap, err := testexe.ParseCapture(bytes.NewReader(content))
100+
if err != nil {
101+
t.Fatal(err)
102+
}
103+
if len(cap.Env) != 2 || cap.Env[0] != envVar1+"="+envValue || cap.Env[1] != envVar2+"="+envValue {
104+
t.Fatal("env mismatch: " + strings.Join(cap.Env, ", "))
105+
}
106+
107+
testexe.TestCommandAssert(t, echo.TestCommand(t, cmdArgs...), cap)
108+
}
109+
110+
func TestGoldenEnv(t *testing.T) {
111+
t.Parallel()
112+
113+
const golden = "echo/testdata/echo_env.golden"
114+
/*
115+
// Initial creation of the golden file:
116+
cmd := echo.TestCommand(t, "-stdout=OK")
117+
cmd.Env = append(os.Environ(), "TEST_CAPTURE_ENV_VAR2=42", "TEST_CAPTURE_ENV_VAR1=42")
118+
err := testexe.WriteCapture(cmd, golden)
119+
if err != nil {
120+
t.Fatal(err)
121+
}
122+
*/
123+
124+
echo.TestAssert(t, golden)
125+
}

internal/testexe/cover-testexe.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ mkdir .coverage.1 .coverage.2 .coverage
88
go test -cover -coverpkg ./... -parallel=10 -race -args -test.gocoverdir="$(pwd)"/.coverage.1
99
GOCOVERDIR=$(pwd)/.coverage.2 go test ./...
1010
go tool covdata merge -pcombine -i .coverage.1,.coverage.2 -o .coverage
11+
go tool covdata percent -i .coverage
1112
go tool covdata textfmt -i .coverage -o .coverage.out
1213
rm -rf .coverage.1 .coverage.2 .coverage
1314

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
echo -stdout=OK
2+
3+
-- env --
4+
TEST_CAPTURE_ENV_VAR1=42
5+
TEST_CAPTURE_ENV_VAR2=42
6+
-- stdout --
7+
OK

internal/testexe/golden/golden.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ func replay() {
8686

8787
if _, err := os.Stat(defaultExe); err == nil {
8888
res.Args[0] = defaultExe
89-
} else if !os.IsNotExist(err) {
89+
} else if os.IsNotExist(err) {
9090
c, err := exec.LookPath(res.Args[0])
9191
if err != nil {
9292
fmt.Fprintf(os.Stderr, "lookpath: %v\n", err)
@@ -103,6 +103,7 @@ func replay() {
103103
err = testexe.CommandAssert(cmd, res)
104104
if err != nil {
105105
fmt.Fprintf(os.Stderr, "assert: %v\n", err)
106+
res.WriteTo(os.Stderr)
106107
os.Exit(1)
107108
}
108109
}

internal/testexe/golden/golden_test.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,20 @@ var golden = testexe.Main{
2828
Verbose: true,
2929
}
3030

31-
func TestGolden(t *testing.T) {
31+
func TestUsage(t *testing.T) {
3232
t.Parallel()
3333

34-
t.Log(t.TempDir())
35-
golden.TestWriteCapture(t, "testdata/golden-echo."+runtime.GOOS+".golden", os.DevNull, "go", "run", "../echo", "-stdout=OK", "-stderr=err", "-exit=2")
34+
golden.TestWriteCapture(t, "testdata/usage.golden", "-h")
35+
}
36+
37+
func TestCapture(t *testing.T) {
38+
t.Parallel()
39+
40+
golden.TestWriteCapture(t, "testdata/capture."+runtime.GOOS+".golden", os.DevNull, "go", "run", "../echo", "-stdout=OK", "-stderr=err", "-exit=2")
41+
}
42+
43+
func TestReplay(t *testing.T) {
44+
t.Parallel()
45+
46+
golden.TestAssert(t, "testdata/replay.golden")
3647
}

internal/testexe/golden/testdata/golden-echo.darwin.golden renamed to internal/testexe/golden/testdata/capture.darwin.golden

File renamed without changes.

internal/testexe/golden/testdata/golden-echo.linux.golden renamed to internal/testexe/golden/testdata/capture.linux.golden

File renamed without changes.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
go run github.com/dolmen-go/goeval/internal/testexe/echo -stdout=OK
2+
3+
-- stdout --
4+
OK
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
golden testdata/echo.golden

0 commit comments

Comments
 (0)