返回 DeepSeek-Reasonix
runner_test.go
根目录 / internal / shellrun / runner_test.go
1 package shellrun
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "os/exec"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13
14 "reasonix/internal/proc"
15 "reasonix/internal/sandbox"
16 "reasonix/internal/tool"
17 )
18
19 func TestDescriptorFromShell(t *testing.T) {
20 tests := []struct {
21 name string
22 sh sandbox.Shell
23 wantShell string
24 wantVersion string
25 wantAndAnd bool
26 }{
27 {
28 name: "posix bash",
29 sh: sandbox.Shell{Kind: sandbox.ShellBash, Path: "/bin/bash"},
30 wantShell: tool.ShellNameBash,
31 wantAndAnd: true,
32 },
33 {
34 name: "git bash path",
35 sh: sandbox.Shell{Kind: sandbox.ShellBash, Path: `C:\Program Files\Git\bin\bash.exe`},
36 wantShell: tool.ShellNameGitBash,
37 wantAndAnd: true,
38 },
39 {
40 name: "macOS zsh fallback",
41 sh: sandbox.Shell{Kind: sandbox.ShellZsh, Path: "/bin/zsh"},
42 wantShell: tool.ShellNameZsh,
43 wantAndAnd: true,
44 },
45 {
46 name: "POSIX sh fallback",
47 sh: sandbox.Shell{Kind: sandbox.ShellSh, Path: "/bin/sh"},
48 wantShell: tool.ShellNameSh,
49 wantAndAnd: true,
50 },
51 {
52 name: "windows powershell 5.1",
53 sh: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`},
54 wantShell: tool.ShellNamePowerShell,
55 wantVersion: tool.ShellVersionPS51,
56 wantAndAnd: false,
57 },
58 {
59 name: "pwsh 7+",
60 sh: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: `C:\Program Files\PowerShell\7\pwsh.exe`},
61 wantShell: tool.ShellNamePwsh,
62 wantVersion: tool.ShellVersionPS7,
63 wantAndAnd: true,
64 },
65 }
66 for _, tt := range tests {
67 t.Run(tt.name, func(t *testing.T) {
68 got := DescriptorFromShell(tt.sh)
69 if got.Shell != tt.wantShell {
70 t.Fatalf("Shell = %q, want %q", got.Shell, tt.wantShell)
71 }
72 if got.ShellVersion != tt.wantVersion {
73 t.Fatalf("ShellVersion = %q, want %q", got.ShellVersion, tt.wantVersion)
74 }
75 if got.SupportsAndAnd != tt.wantAndAnd {
76 t.Fatalf("SupportsAndAnd = %v, want %v", got.SupportsAndAnd, tt.wantAndAnd)
77 }
78 if got.Kind != "shell" {
79 t.Fatalf("Kind = %q", got.Kind)
80 }
81 if got.Platform == "" {
82 t.Fatal("Platform empty")
83 }
84 })
85 }
86 }
87
88 func TestDisplayName(t *testing.T) {
89 if got := DisplayName(DescriptorFromShell(sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "powershell"})); got != "Windows PowerShell" {
90 t.Fatalf("got %q", got)
91 }
92 if got := DisplayName(DescriptorFromShell(sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "pwsh"})); got != "PowerShell 7+" {
93 t.Fatalf("got %q", got)
94 }
95 if got := DisplayName(DescriptorFromShell(sandbox.Shell{Kind: sandbox.ShellBash, Path: `C:\Program Files\Git\bin\bash.exe`})); got != "Git Bash" {
96 t.Fatalf("got %q", got)
97 }
98 }
99
100 func TestRunForegroundSuccess(t *testing.T) {
101 argv, sh := shellArgv(t, "printf 'ok\\n'")
102 res := RunForeground(context.Background(), Request{
103 Argv: argv,
104 ShellKind: sh.Kind.String(),
105 ShellPath: sh.Path,
106 Track: true,
107 })
108 if res.Err != nil {
109 t.Fatalf("err = %v", res.Err)
110 }
111 if res.State != tool.ShellStateCompleted {
112 t.Fatalf("state = %q", res.State)
113 }
114 if res.ExitCode == nil || *res.ExitCode != 0 {
115 t.Fatalf("exitCode = %v", res.ExitCode)
116 }
117 if !strings.Contains(res.Combined, "ok") {
118 t.Fatalf("combined = %q", res.Combined)
119 }
120 }
121
122 func TestRunForegroundNonZeroExit(t *testing.T) {
123 argv, sh := shellArgv(t, "exit 7")
124 res := RunForeground(context.Background(), Request{
125 Argv: argv,
126 ShellKind: sh.Kind.String(),
127 ShellPath: sh.Path,
128 Track: true,
129 })
130 if res.Err == nil {
131 t.Fatal("expected error")
132 }
133 if res.State != tool.ShellStateFailed || res.FailurePhase != tool.ShellPhaseExecution {
134 t.Fatalf("state/phase = %s/%s", res.State, res.FailurePhase)
135 }
136 if res.ExitCode == nil || *res.ExitCode == 0 {
137 t.Fatalf("exitCode = %v", res.ExitCode)
138 }
139 }
140
141 func TestRunForegroundTimeout(t *testing.T) {
142 cmd := "sleep 5"
143 sh := sandbox.ResolveShell("auto", "", nil)
144 if sh.Kind == sandbox.ShellPowerShell {
145 cmd = "Start-Sleep -Seconds 5"
146 }
147 argv, _ := shellArgv(t, cmd)
148 res := RunForeground(context.Background(), Request{
149 Argv: argv,
150 Timeout: 200 * time.Millisecond,
151 ShellKind: sh.Kind.String(),
152 ShellPath: sh.Path,
153 Track: true,
154 })
155 if res.State != tool.ShellStateTimedOut || res.FailurePhase != tool.ShellPhaseTimeout {
156 t.Fatalf("state/phase = %s/%s err=%v", res.State, res.FailurePhase, res.Err)
157 }
158 }
159
160 func TestRunForegroundLaunchFailure(t *testing.T) {
161 res := RunForeground(context.Background(), Request{
162 Argv: []string{"/nonexistent/reasonix-shell-binary-xyz", "-c", "echo hi"},
163 Track: false,
164 Run: func(ctx context.Context, cmd *exec.Cmd, opts proc.RunOptions) (*proc.TrackedCommand, error) {
165 return nil, errors.New("exec: no such file")
166 },
167 })
168 if res.State != tool.ShellStateFailed || res.FailurePhase != tool.ShellPhaseLaunch {
169 t.Fatalf("state/phase = %s/%s", res.State, res.FailurePhase)
170 }
171 if res.ExitCode != nil {
172 t.Fatalf("exitCode should be nil for launch failure, got %v", *res.ExitCode)
173 }
174 }
175
176 func TestRunForegroundOutputTailBounded(t *testing.T) {
177 payload := strings.Repeat("中文", 3000)
178 // Keep the command under typical argv length limits.
179 if len(payload) > 4000 {
180 payload = payload[:4000]
181 }
182 sh := sandbox.ResolveShell("auto", "", nil)
183 var command string
184 if sh.Kind == sandbox.ShellPowerShell {
185 command = `[Console]::Error.Write('` + strings.ReplaceAll(payload, "'", "''") + `')`
186 } else {
187 command = "printf '%s' '" + strings.ReplaceAll(payload, "'", `'\"'\"'`) + "' 1>&2"
188 }
189 argv := shellArgvWith(sh, command)
190 res := RunForeground(context.Background(), Request{
191 Argv: argv,
192 ShellKind: sh.Kind.String(),
193 ShellPath: sh.Path,
194 Track: true,
195 })
196 if len(res.OutputTail) > tool.OutputTailMaxBytes {
197 t.Fatalf("output tail %d > %d", len(res.OutputTail), tool.OutputTailMaxBytes)
198 }
199 if !strings.Contains(res.Combined, "中文") && !strings.Contains(res.OutputTail, "中文") {
200 t.Fatalf("UTF-8 Chinese lost: combined=%q tail=%q", trim(res.Combined, 80), trim(res.OutputTail, 80))
201 }
202 }
203
204 func TestRunForegroundCombinedOutputBounded(t *testing.T) {
205 head := strings.Repeat("H", combinedOutputMaxBytes)
206 tail := strings.Repeat("T", combinedOutputTailBytes)
207 res := RunForeground(context.Background(), Request{
208 Argv: []string{"irrelevant"},
209 Run: func(_ context.Context, cmd *exec.Cmd, _ proc.RunOptions) (*proc.TrackedCommand, error) {
210 if _, err := io.WriteString(cmd.Stdout, head); err != nil {
211 return nil, err
212 }
213 if _, err := io.WriteString(cmd.Stdout, tail); err != nil {
214 return nil, err
215 }
216 return nil, nil
217 },
218 })
219 if res.Err != nil {
220 t.Fatalf("RunForeground: %v", res.Err)
221 }
222 if len(res.Combined) > combinedOutputMaxBytes {
223 t.Fatalf("combined output bytes = %d, want <= %d", len(res.Combined), combinedOutputMaxBytes)
224 }
225 if !strings.HasPrefix(res.Combined, "HHHH") {
226 t.Fatal("combined output lost its opening context")
227 }
228 if !strings.Contains(res.Combined, combinedOutputTruncated) {
229 t.Fatal("combined output omitted the truncation notice")
230 }
231 if !strings.HasSuffix(res.Combined, tail) {
232 t.Fatal("combined output lost its final diagnostics")
233 }
234 }
235
236 func TestRunForegroundProgressBounded(t *testing.T) {
237 payload := strings.Repeat("x", progressOutputMaxBytes+(1<<20))
238 var progress strings.Builder
239 res := RunForeground(context.Background(), Request{
240 Argv: []string{"irrelevant"},
241 Progress: func(chunk string) { progress.WriteString(chunk) },
242 Run: func(_ context.Context, cmd *exec.Cmd, _ proc.RunOptions) (*proc.TrackedCommand, error) {
243 _, err := io.WriteString(cmd.Stdout, payload)
244 return nil, err
245 },
246 })
247 if res.Err != nil {
248 t.Fatalf("RunForeground: %v", res.Err)
249 }
250 if got, max := progress.Len(), progressOutputMaxBytes+len(progressOutputTruncated); got > max {
251 t.Fatalf("progress bytes = %d, want <= %d", got, max)
252 }
253 if !strings.Contains(progress.String(), progressOutputTruncated) {
254 t.Fatal("progress omitted the truncation notice")
255 }
256 if len(res.Combined) != len(payload) {
257 t.Fatalf("progress cap changed final output: got %d bytes, want %d", len(res.Combined), len(payload))
258 }
259 }
260
261 func TestRunForegroundCombinedOutputCapIsConcurrentSafe(t *testing.T) {
262 chunk := strings.Repeat("x", 128<<10)
263 var progressMu sync.Mutex
264 progressBytes := 0
265 progressMarkers := 0
266 res := RunForeground(context.Background(), Request{
267 Argv: []string{"irrelevant"},
268 Progress: func(chunk string) {
269 progressMu.Lock()
270 defer progressMu.Unlock()
271 progressBytes += len(chunk)
272 progressMarkers += strings.Count(chunk, progressOutputTruncated)
273 },
274 Run: func(_ context.Context, cmd *exec.Cmd, _ proc.RunOptions) (*proc.TrackedCommand, error) {
275 var wg sync.WaitGroup
276 for range 4 {
277 wg.Go(func() {
278 for range 32 {
279 _, _ = io.WriteString(cmd.Stdout, chunk)
280 }
281 })
282 }
283 wg.Wait()
284 return nil, nil
285 },
286 })
287 if res.Err != nil {
288 t.Fatalf("RunForeground: %v", res.Err)
289 }
290 if len(res.Combined) > combinedOutputMaxBytes {
291 t.Fatalf("combined output bytes = %d, want <= %d", len(res.Combined), combinedOutputMaxBytes)
292 }
293 if !strings.Contains(res.Combined, combinedOutputTruncated) {
294 t.Fatal("combined output omitted the truncation notice")
295 }
296 if max := progressOutputMaxBytes + len(progressOutputTruncated); progressBytes > max {
297 t.Fatalf("progress bytes = %d, want <= %d", progressBytes, max)
298 }
299 if progressMarkers != 1 {
300 t.Fatalf("progress truncation markers = %d, want 1", progressMarkers)
301 }
302 }
303
304 // TestRunForegroundSharesOnePipeForStdoutAndStderr pins the mechanism behind
305 // ordered combined output: os/exec reuses a single pipe and a single copy
306 // goroutine only while Stdout and Stderr hold the same writer value. Giving them
307 // two writers (for example to tee stderr into its own tail) silently splits the
308 // child's streams into two pipes, and the model then reads reordered output.
309 func TestRunForegroundSharesOnePipeForStdoutAndStderr(t *testing.T) {
310 var captured *exec.Cmd
311 RunForeground(context.Background(), Request{
312 Argv: []string{"irrelevant"},
313 Progress: func(string) {},
314 Run: func(_ context.Context, cmd *exec.Cmd, _ proc.RunOptions) (*proc.TrackedCommand, error) {
315 captured = cmd
316 return nil, nil
317 },
318 })
319 if captured == nil {
320 t.Fatal("runner never built a command")
321 }
322 if captured.Stdout == nil || captured.Stdout != captured.Stderr {
323 t.Fatalf("Stdout and Stderr must be the same writer value; got %p and %p", captured.Stdout, captured.Stderr)
324 }
325 }
326
327 // TestRunForegroundPreservesInterleaving is the behavioral half of the same
328 // contract: what the child wrote first must still come first.
329 func TestRunForegroundPreservesInterleaving(t *testing.T) {
330 sh := sandbox.ResolveShell("auto", "", nil)
331 if sh.Kind == sandbox.ShellPowerShell {
332 t.Skip("stream-buffering semantics differ on PowerShell; the pipe-identity test covers the mechanism")
333 }
334 const rounds = 8
335 var want strings.Builder
336 for i := 1; i <= rounds; i++ {
337 fmt.Fprintf(&want, "out%d\nerr%d\n", i, i)
338 }
339 argv := shellArgvWith(sh, "for i in 1 2 3 4 5 6 7 8; do echo out$i; echo err$i 1>&2; done")
340 // Repeat: two pipes reorder probabilistically, so one run can pass by luck.
341 for run := range 10 {
342 res := RunForeground(context.Background(), Request{Argv: argv, Timeout: 30 * time.Second})
343 if res.Combined != want.String() {
344 t.Fatalf("run %d lost child write order:\ngot %q\nwant %q", run, res.Combined, want.String())
345 }
346 }
347 }
348
349 // TestRunForegroundDropsTailOnSuccess keeps a successful command from carrying
350 // up to 16 KiB of ordinary stdout into the session record and the tool card.
351 func TestRunForegroundDropsTailOnSuccess(t *testing.T) {
352 argv, _ := shellArgv(t, "echo hello")
353 res := RunForeground(context.Background(), Request{Argv: argv, Timeout: 30 * time.Second})
354 if res.State != tool.ShellStateCompleted {
355 t.Fatalf("State = %q, want %q", res.State, tool.ShellStateCompleted)
356 }
357 if !strings.Contains(res.Combined, "hello") {
358 t.Fatalf("Combined = %q, want it to contain the output", res.Combined)
359 }
360 if res.OutputTail != "" {
361 t.Fatalf("OutputTail = %q, want empty on success", res.OutputTail)
362 }
363 }
364
365 func shellArgv(t *testing.T, command string) ([]string, sandbox.Shell) {
366 t.Helper()
367 sh := sandbox.ResolveShell("auto", "", nil)
368 return shellArgvWith(sh, command), sh
369 }
370
371 func shellArgvWith(sh sandbox.Shell, command string) []string {
372 path := sh.Path
373 if path == "" {
374 path = sh.Kind.String()
375 }
376 if sh.Kind == sandbox.ShellPowerShell {
377 return []string{path, "-NoProfile", "-NonInteractive", "-Command", sandbox.PowerShellUTF8Script(command)}
378 }
379 return []string{path, "-c", command}
380 }
381
382 func trim(s string, n int) string {
383 if len(s) <= n {
384 return s
385 }
386 return s[:n]
387 }
388
388 lines GO