@@ -0,0 +1,252 @@
14+func TestRun_Echo(t *testing.T) {
15+ var stdout, stderr bytes.Buffer
16+ err := Run(t.Context(), RunOptions{
23+ t.Fatalf("Run returned error: %v (stderr=%q)", err, stderr.String())
25+ if got := stdout.String(); got != "hi\n" {
26+ t.Fatalf("stdout = %q, want %q", got, "hi\n")
30+func TestRun_ExitCode(t *testing.T) {
31+ err := Run(t.Context(), RunOptions{
36+ t.Fatal("expected error for exit 7, got nil")
38+ if code := ExitCode(err); code != 7 {
39+ t.Fatalf("ExitCode = %d, want 7", code)
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",
50+ Stdin: strings.NewReader("hello\n"),
54+ t.Fatalf("Run returned error: %v", err)
56+ if got := stdout.String(); got != "got:hello\n" {
57+ t.Fatalf("stdout = %q, want %q", got, "got:hello\n")
61+func TestRun_Env(t *testing.T) {
62+ var stdout bytes.Buffer
63+ err := Run(t.Context(), RunOptions{
64+ Command: `echo "$FOO"`,
66+ Env: []string{"FOO=bar"},
70+ t.Fatalf("Run returned error: %v", err)
72+ if got := stdout.String(); got != "bar\n" {
73+ t.Fatalf("stdout = %q, want %q", got, "bar\n")
77+func TestRun_Cwd(t *testing.T) {
79+ var stdout bytes.Buffer
80+ err := Run(t.Context(), RunOptions{
86+ t.Fatalf("Run returned error: %v", err)
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)
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`,
104+ t.Fatalf("Run returned error: %v", err)
106+ if got := stdout.String(); got != "1\n" {
107+ t.Fatalf("stdout = %q, want %q", got, "1\n")
111+func TestRun_ParallelIsolation(t *testing.T) {
113+ var wg sync.WaitGroup
115+ errs := make([]error, n)
116+ outs := make([]string, n)
117+ dirs := make([]string, n)
119+ dirs[i] = t.TempDir()
122+ var stdout bytes.Buffer
123+ errs[i] = Run(t.Context(), RunOptions{
124+ Command: `echo "$MARKER"`,
126+ Env: []string{fmt.Sprintf("MARKER=id-%d", i)},
129+ outs[i] = stdout.String()
135+ t.Errorf("goroutine %d: err = %v", i, errs[i])
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)
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)
153+ done := make(chan error, 1)
155+ done <- Run(ctx, RunOptions{
156+ Command: "while true; do :; done",
163+ if !IsInterrupt(err) && !errors.Is(err, context.DeadlineExceeded) {
164+ t.Fatalf("expected interrupt/deadline error, got: %v", err)
166+ case <-time.After(1500 * time.Millisecond):
167+ t.Fatal("Run did not return within 1.5s after ctx cancel")
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)
178+ done := make(chan error, 1)
181+ done <- Run(ctx, RunOptions{
182+ Command: "sleep 30",
189+ elapsed := time.Since(start)
190+ if elapsed > time.Second {
191+ t.Fatalf("sleep took too long to cancel: %v", elapsed)
194+ t.Fatal("expected non-nil error from cancelled sleep")
196+ case <-time.After(time.Second):
197+ t.Fatal("Run did not return within 1s after ctx cancel")
201+func TestRun_ParseError(t *testing.T) {
202+ err := Run(t.Context(), RunOptions{
203+ Command: "echo 'unterminated",
207+ t.Fatal("expected parse error, got nil")
209+ if !strings.Contains(err.Error(), "parse") {
210+ t.Fatalf("error should mention parse: %v", err)
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",
221+ BlockFuncs: []BlockFunc{block},
224+ t.Fatal("expected error when running blocked command")
226+ if !strings.Contains(err.Error(), "not allowed") {
227+ t.Fatalf("expected 'not allowed' error, got: %v", err)
231+func TestRun_RequiresCwd(t *testing.T) {
232+ err := Run(t.Context(), RunOptions{
236+ t.Fatal("expected error when Cwd is empty, got nil")
238+ if !strings.Contains(err.Error(), "Cwd is required") {
239+ t.Fatalf("error should mention Cwd requirement: %v", err)
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",
250+ t.Fatalf("Run returned error: %v", err)