@@ -18,6 +18,7 @@ package testexe
1818
1919import (
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).
3676func 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
91153func 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.
276362func 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\n unexpected 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 )
0 commit comments