1package shellcmd
2
3import (
4 "context"
5 "errors"
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/bnema/vev/internal/ports"
11 "github.com/stretchr/testify/require"
12)
13
14func TestRunnerRun(t *testing.T) {
15 tests := []struct {
16 name string
17 command string
18 limit int
19 want string
20 }{
21 {name: "success", command: "printf 'ok'", limit: 1024, want: "ok"},
22 {name: "bounded stdout", command: "yes a | head -c 4096", limit: 8, want: "a\na\na\na\n"},
23 {name: "stderr does not leak into stdout", command: "printf out; printf err >&2", limit: 1024, want: "out"},
24 {name: "zero limit captures nothing", command: "printf output", limit: 0, want: ""},
25 {name: "negative limit captures nothing", command: "printf output", limit: -1, want: ""},
26 }
27 for _, tt := range tests {
28 t.Run(tt.name, func(t *testing.T) {
29 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
30 defer cancel()
31
32 got, err := New().Run(ctx, ports.CommandSpec{Command: tt.command, Timeout: time.Second, StdoutLimit: tt.limit})
33 if err != nil {
34 t.Fatalf("Run() error = %v", err)
35 }
36 if string(got.Stdout) != tt.want {
37 t.Fatalf("Run() = %q, want %q", string(got.Stdout), tt.want)
38 }
39 })
40 }
41}
42
43func TestRunnerRunReturnsPartialStdoutOnError(t *testing.T) {
44 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
45 defer cancel()
46
47 got, err := New().Run(ctx, ports.CommandSpec{Command: "printf 'abcdef'; exit 7", Timeout: time.Second, StdoutLimit: 3})
48 if err == nil {
49 t.Fatal("Run() error = nil, want non-zero exit error")
50 }
51 if string(got.Stdout) != "abc" {
52 t.Fatalf("Run() = %q, want bounded partial stdout %q", string(got.Stdout), "abc")
53 }
54}
55
56func TestRunnerRunTimeoutReturnsPromptly(t *testing.T) {
57 ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
58 defer cancel()
59 start := time.Now()
60
61 _, err := New().Run(ctx, ports.CommandSpec{Command: "sleep 1", Timeout: 50 * time.Millisecond, StdoutLimit: 1024})
62
63 if err == nil {
64 t.Fatal("Run() error = nil, want timeout or wait error")
65 }
66 if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
67 t.Fatalf("context error = %v, want DeadlineExceeded", ctx.Err())
68 }
69 if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
70 t.Fatalf("Run() elapsed = %s, want <= 500ms", elapsed)
71 }
72}
73
74func TestRunnerRunBackgroundChildDoesNotWedgeAfterTimeout(t *testing.T) {
75 ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
76 defer cancel()
77 start := time.Now()
78
79 _, err := New().Run(ctx, ports.CommandSpec{Command: "sleep 1 &", Timeout: 50 * time.Millisecond, StdoutLimit: 1024})
80
81 if err == nil {
82 t.Fatal("Run() error = nil, want timeout or wait error")
83 }
84 if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
85 t.Fatalf("context error = %v, want DeadlineExceeded", ctx.Err())
86 }
87 if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
88 t.Fatalf("Run() elapsed = %s, want <= 500ms", elapsed)
89 }
90}
91
92func TestRunnerRunUsesProvidedEnvironment(t *testing.T) {
93 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
94 defer cancel()
95
96 got, err := New().Run(ctx, ports.CommandSpec{
97 Command: "printf '%s' \"$VEV_TEST_VALUE\"",
98 Env: []string{"PATH=/bin:/usr/bin", "VEV_TEST_VALUE=from-env"},
99 Timeout: time.Second,
100 StdoutLimit: 1024,
101 })
102 if err != nil {
103 t.Fatalf("Run() error = %v", err)
104 }
105 if strings.TrimSpace(string(got.Stdout)) != "from-env" {
106 t.Fatalf("Run() env output = %q, want from-env", string(got.Stdout))
107 }
108}
109
110func TestRunnerCapturesStderrAndExitCode(t *testing.T) {
111 tests := []struct {
112 name string
113 command string
114 wantExitCode int
115 wantStderr string
116 wantStdout string
117 }{
118 {
119 name: "command not found reports 127 and stderr",
120 command: "definitely-not-a-real-command-xyz",
121 wantExitCode: 127,
122 wantStderr: "not found",
123 },
124 {
125 name: "explicit exit code with stderr",
126 command: "echo oops >&2; exit 3",
127 wantExitCode: 3,
128 wantStderr: "oops",
129 },
130 {
131 name: "success reports zero and stdout",
132 command: "echo hello",
133 wantExitCode: 0,
134 wantStdout: "hello",
135 },
136 }
137
138 for _, tc := range tests {
139 t.Run(tc.name, func(t *testing.T) {
140 res, err := New().Run(context.Background(), ports.CommandSpec{
141 Command: tc.command,
142 Timeout: 5 * time.Second,
143 StdoutLimit: 1024,
144 StderrLimit: 1024,
145 })
146 if tc.wantExitCode == 0 {
147 require.NoError(t, err)
148 } else {
149 require.Error(t, err)
150 }
151 require.Equal(t, tc.wantExitCode, res.ExitCode)
152 if tc.wantStderr != "" {
153 require.Contains(t, string(res.Stderr), tc.wantStderr)
154 }
155 if tc.wantStdout != "" {
156 require.Contains(t, string(res.Stdout), tc.wantStdout)
157 }
158 })
159 }
160}
161
162func TestRunnerBoundsStderr(t *testing.T) {
163 res, _ := New().Run(context.Background(), ports.CommandSpec{
164 Command: "yes badness 2>/dev/null | head -c 100000 >&2",
165 Timeout: 5 * time.Second,
166 StdoutLimit: 1024,
167 StderrLimit: 64,
168 })
169 require.Equal(t, 64, len(res.Stderr), "stderr must be captured and truncated to StderrLimit")
170}