run_test.go

  1package shell
  2
  3import (
  4	"bytes"
  5	"context"
  6	"errors"
  7	"fmt"
  8	"strings"
  9	"sync"
 10	"testing"
 11	"time"
 12)
 13
 14func TestRun_Echo(t *testing.T) {
 15	var stdout, stderr bytes.Buffer
 16	err := Run(t.Context(), RunOptions{
 17		Command: "echo hi",
 18		Cwd:     t.TempDir(),
 19		Stdout:  &stdout,
 20		Stderr:  &stderr,
 21	})
 22	if err != nil {
 23		t.Fatalf("Run returned error: %v (stderr=%q)", err, stderr.String())
 24	}
 25	if got := stdout.String(); got != "hi\n" {
 26		t.Fatalf("stdout = %q, want %q", got, "hi\n")
 27	}
 28}
 29
 30func TestRun_ExitCode(t *testing.T) {
 31	err := Run(t.Context(), RunOptions{
 32		Command: "exit 7",
 33		Cwd:     t.TempDir(),
 34	})
 35	if err == nil {
 36		t.Fatal("expected error for exit 7, got nil")
 37	}
 38	if code := ExitCode(err); code != 7 {
 39		t.Fatalf("ExitCode = %d, want 7", code)
 40	}
 41}
 42
 43func TestRun_Stdin(t *testing.T) {
 44	// Use the `read` shell builtin so the test doesn't depend on any
 45	// external binary being on PATH (we pass an empty Env here).
 46	var stdout bytes.Buffer
 47	err := Run(t.Context(), RunOptions{
 48		Command: "read line; echo got:$line",
 49		Cwd:     t.TempDir(),
 50		Stdin:   strings.NewReader("hello\n"),
 51		Stdout:  &stdout,
 52	})
 53	if err != nil {
 54		t.Fatalf("Run returned error: %v", err)
 55	}
 56	if got := stdout.String(); got != "got:hello\n" {
 57		t.Fatalf("stdout = %q, want %q", got, "got:hello\n")
 58	}
 59}
 60
 61func TestRun_Env(t *testing.T) {
 62	var stdout bytes.Buffer
 63	err := Run(t.Context(), RunOptions{
 64		Command: `echo "$FOO"`,
 65		Cwd:     t.TempDir(),
 66		Env:     []string{"FOO=bar"},
 67		Stdout:  &stdout,
 68	})
 69	if err != nil {
 70		t.Fatalf("Run returned error: %v", err)
 71	}
 72	if got := stdout.String(); got != "bar\n" {
 73		t.Fatalf("stdout = %q, want %q", got, "bar\n")
 74	}
 75}
 76
 77func TestRun_Cwd(t *testing.T) {
 78	dir := t.TempDir()
 79	var stdout bytes.Buffer
 80	err := Run(t.Context(), RunOptions{
 81		Command: "pwd",
 82		Cwd:     dir,
 83		Stdout:  &stdout,
 84	})
 85	if err != nil {
 86		t.Fatalf("Run returned error: %v", err)
 87	}
 88	// mvdan's pwd builtin resolves symlinks (e.g. /var -> /private/var on
 89	// macOS). Compare against a suffix so we don't get bitten by that.
 90	got := strings.TrimRight(stdout.String(), "\n")
 91	if !strings.HasSuffix(got, dir) && !strings.HasSuffix(dir, got) {
 92		t.Fatalf("pwd = %q, want it to match %q", got, dir)
 93	}
 94}
 95
 96func TestRun_JqBuiltin(t *testing.T) {
 97	var stdout bytes.Buffer
 98	err := Run(t.Context(), RunOptions{
 99		Command: `echo '{"a":1}' | jq .a`,
100		Cwd:     t.TempDir(),
101		Stdout:  &stdout,
102	})
103	if err != nil {
104		t.Fatalf("Run returned error: %v", err)
105	}
106	if got := stdout.String(); got != "1\n" {
107		t.Fatalf("stdout = %q, want %q", got, "1\n")
108	}
109}
110
111func TestRun_ParallelIsolation(t *testing.T) {
112	const n = 10
113	var wg sync.WaitGroup
114	wg.Add(n)
115	errs := make([]error, n)
116	outs := make([]string, n)
117	dirs := make([]string, n)
118	for i := range n {
119		dirs[i] = t.TempDir()
120		go func(i int) {
121			defer wg.Done()
122			var stdout bytes.Buffer
123			errs[i] = Run(t.Context(), RunOptions{
124				Command: `echo "$MARKER"`,
125				Cwd:     dirs[i],
126				Env:     []string{fmt.Sprintf("MARKER=id-%d", i)},
127				Stdout:  &stdout,
128			})
129			outs[i] = stdout.String()
130		}(i)
131	}
132	wg.Wait()
133	for i := range n {
134		if errs[i] != nil {
135			t.Errorf("goroutine %d: err = %v", i, errs[i])
136			continue
137		}
138		want := fmt.Sprintf("id-%d\n", i)
139		if outs[i] != want {
140			t.Errorf("goroutine %d: stdout = %q, want %q", i, outs[i], want)
141		}
142	}
143}
144
145// TestRun_CtxCancel_BusyLoop verifies that a pure-shell loop respects ctx
146// cancellation. mvdan's interpreter checks ctx between statements, so this
147// should return quickly even without any external command. The test bounds
148// its own wait via a select so a regression can't hang CI.
149func TestRun_CtxCancel_BusyLoop(t *testing.T) {
150	ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond)
151	t.Cleanup(cancel)
152
153	done := make(chan error, 1)
154	go func() {
155		done <- Run(ctx, RunOptions{
156			Command: "while true; do :; done",
157			Cwd:     t.TempDir(),
158		})
159	}()
160
161	select {
162	case err := <-done:
163		if !IsInterrupt(err) && !errors.Is(err, context.DeadlineExceeded) {
164			t.Fatalf("expected interrupt/deadline error, got: %v", err)
165		}
166	case <-time.After(1500 * time.Millisecond):
167		t.Fatal("Run did not return within 1.5s after ctx cancel")
168	}
169}
170
171// TestRun_CtxCancel_ExternalSleep verifies ctx cancellation reaches an
172// external process via mvdan's default exec. Uses sleep, which lives in
173// coreutils on Windows and /bin on Unix.
174func TestRun_CtxCancel_ExternalSleep(t *testing.T) {
175	ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
176	t.Cleanup(cancel)
177
178	done := make(chan error, 1)
179	start := time.Now()
180	go func() {
181		done <- Run(ctx, RunOptions{
182			Command: "sleep 30",
183			Cwd:     t.TempDir(),
184		})
185	}()
186
187	select {
188	case err := <-done:
189		elapsed := time.Since(start)
190		if elapsed > time.Second {
191			t.Fatalf("sleep took too long to cancel: %v", elapsed)
192		}
193		if err == nil {
194			t.Fatal("expected non-nil error from cancelled sleep")
195		}
196	case <-time.After(time.Second):
197		t.Fatal("Run did not return within 1s after ctx cancel")
198	}
199}
200
201func TestRun_ParseError(t *testing.T) {
202	err := Run(t.Context(), RunOptions{
203		Command: "echo 'unterminated",
204		Cwd:     t.TempDir(),
205	})
206	if err == nil {
207		t.Fatal("expected parse error, got nil")
208	}
209	if !strings.Contains(err.Error(), "parse") {
210		t.Fatalf("error should mention parse: %v", err)
211	}
212}
213
214func TestRun_BlockFuncs(t *testing.T) {
215	block := CommandsBlocker([]string{"forbidden"})
216	var stderr bytes.Buffer
217	err := Run(t.Context(), RunOptions{
218		Command:    "forbidden",
219		Cwd:        t.TempDir(),
220		Stderr:     &stderr,
221		BlockFuncs: []BlockFunc{block},
222	})
223	if err == nil {
224		t.Fatal("expected error when running blocked command")
225	}
226	if !strings.Contains(err.Error(), "not allowed") {
227		t.Fatalf("expected 'not allowed' error, got: %v", err)
228	}
229}
230
231func TestRun_RequiresCwd(t *testing.T) {
232	err := Run(t.Context(), RunOptions{
233		Command: "echo hi",
234	})
235	if err == nil {
236		t.Fatal("expected error when Cwd is empty, got nil")
237	}
238	if !strings.Contains(err.Error(), "Cwd is required") {
239		t.Fatalf("error should mention Cwd requirement: %v", err)
240	}
241}
242
243func TestRun_DiscardsNilWriters(t *testing.T) {
244	// No panic when Stdout/Stderr are nil.
245	err := Run(t.Context(), RunOptions{
246		Command: "echo hi; echo err >&2",
247		Cwd:     t.TempDir(),
248	})
249	if err != nil {
250		t.Fatalf("Run returned error: %v", err)
251	}
252}