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