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}