@@ -0,0 +1,252 @@
1+package shell
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "strings"
9+ "sync"
10+ "testing"
11+ "time"
12+)
13+
14+func 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+
30+func 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+
43+func 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+
61+func 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+
77+func 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+
96+func 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+
111+func 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.
149+func 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.
174+func 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+
201+func 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+
214+func 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+
231+func 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+
243+func 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+}