返回 DeepSeek-Reasonix
runner_test.go
根目录 / internal / shellrun / runner_test.go
1 package shellrun
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "os/exec"
8 "strings"
9 "testing"
10 "time"
11
12 "reasonix/internal/proc"
13 "reasonix/internal/sandbox"
14 "reasonix/internal/tool"
15 )
16
17 func TestDescriptorFromShell(t *testing.T) {
18 tests := []struct {
19 name string
20 sh sandbox.Shell
21 wantShell string
22 wantVersion string
23 wantAndAnd bool
24 }{
25 {
26 name: "posix bash",
27 sh: sandbox.Shell{Kind: sandbox.ShellBash, Path: "/bin/bash"},
28 wantShell: tool.ShellNameBash,
29 wantAndAnd: true,
30 },
31 {
32 name: "git bash path",
33 sh: sandbox.Shell{Kind: sandbox.ShellBash, Path: `C:\Program Files\Git\bin\bash.exe`},
34 wantShell: tool.ShellNameGitBash,
35 wantAndAnd: true,
36 },
37 {
38 name: "windows powershell 5.1",
39 sh: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`},
40 wantShell: tool.ShellNamePowerShell,
41 wantVersion: tool.ShellVersionPS51,
42 wantAndAnd: false,
43 },
44 {
45 name: "pwsh 7+",
46 sh: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: `C:\Program Files\PowerShell\7\pwsh.exe`},
47 wantShell: tool.ShellNamePwsh,
48 wantVersion: tool.ShellVersionPS7,
49 wantAndAnd: true,
50 },
51 }
52 for _, tt := range tests {
53 t.Run(tt.name, func(t *testing.T) {
54 got := DescriptorFromShell(tt.sh)
55 if got.Shell != tt.wantShell {
56 t.Fatalf("Shell = %q, want %q", got.Shell, tt.wantShell)
57 }
58 if got.ShellVersion != tt.wantVersion {
59 t.Fatalf("ShellVersion = %q, want %q", got.ShellVersion, tt.wantVersion)
60 }
61 if got.SupportsAndAnd != tt.wantAndAnd {
62 t.Fatalf("SupportsAndAnd = %v, want %v", got.SupportsAndAnd, tt.wantAndAnd)
63 }
64 if got.Kind != "shell" {
65 t.Fatalf("Kind = %q", got.Kind)
66 }
67 if got.Platform == "" {
68 t.Fatal("Platform empty")
69 }
70 })
71 }
72 }
73
74 func TestDisplayName(t *testing.T) {
75 if got := DisplayName(DescriptorFromShell(sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "powershell"})); got != "Windows PowerShell" {
76 t.Fatalf("got %q", got)
77 }
78 if got := DisplayName(DescriptorFromShell(sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "pwsh"})); got != "PowerShell 7+" {
79 t.Fatalf("got %q", got)
80 }
81 if got := DisplayName(DescriptorFromShell(sandbox.Shell{Kind: sandbox.ShellBash, Path: `C:\Program Files\Git\bin\bash.exe`})); got != "Git Bash" {
82 t.Fatalf("got %q", got)
83 }
84 }
85
86 func TestRunForegroundSuccess(t *testing.T) {
87 argv, sh := shellArgv(t, "printf 'ok\\n'")
88 res := RunForeground(context.Background(), Request{
89 Argv: argv,
90 ShellKind: sh.Kind.String(),
91 ShellPath: sh.Path,
92 Track: true,
93 })
94 if res.Err != nil {
95 t.Fatalf("err = %v", res.Err)
96 }
97 if res.State != tool.ShellStateCompleted {
98 t.Fatalf("state = %q", res.State)
99 }
100 if res.ExitCode == nil || *res.ExitCode != 0 {
101 t.Fatalf("exitCode = %v", res.ExitCode)
102 }
103 if !strings.Contains(res.Combined, "ok") {
104 t.Fatalf("combined = %q", res.Combined)
105 }
106 }
107
108 func TestRunForegroundNonZeroExit(t *testing.T) {
109 argv, sh := shellArgv(t, "exit 7")
110 res := RunForeground(context.Background(), Request{
111 Argv: argv,
112 ShellKind: sh.Kind.String(),
113 ShellPath: sh.Path,
114 Track: true,
115 })
116 if res.Err == nil {
117 t.Fatal("expected error")
118 }
119 if res.State != tool.ShellStateFailed || res.FailurePhase != tool.ShellPhaseExecution {
120 t.Fatalf("state/phase = %s/%s", res.State, res.FailurePhase)
121 }
122 if res.ExitCode == nil || *res.ExitCode == 0 {
123 t.Fatalf("exitCode = %v", res.ExitCode)
124 }
125 }
126
127 func TestRunForegroundTimeout(t *testing.T) {
128 cmd := "sleep 5"
129 sh := sandbox.ResolveShell("auto", "", nil)
130 if sh.Kind == sandbox.ShellPowerShell {
131 cmd = "Start-Sleep -Seconds 5"
132 }
133 argv, _ := shellArgv(t, cmd)
134 res := RunForeground(context.Background(), Request{
135 Argv: argv,
136 Timeout: 200 * time.Millisecond,
137 ShellKind: sh.Kind.String(),
138 ShellPath: sh.Path,
139 Track: true,
140 })
141 if res.State != tool.ShellStateTimedOut || res.FailurePhase != tool.ShellPhaseTimeout {
142 t.Fatalf("state/phase = %s/%s err=%v", res.State, res.FailurePhase, res.Err)
143 }
144 }
145
146 func TestRunForegroundLaunchFailure(t *testing.T) {
147 res := RunForeground(context.Background(), Request{
148 Argv: []string{"/nonexistent/reasonix-shell-binary-xyz", "-c", "echo hi"},
149 Track: false,
150 Run: func(ctx context.Context, cmd *exec.Cmd, opts proc.RunOptions) (*proc.TrackedCommand, error) {
151 return nil, errors.New("exec: no such file")
152 },
153 })
154 if res.State != tool.ShellStateFailed || res.FailurePhase != tool.ShellPhaseLaunch {
155 t.Fatalf("state/phase = %s/%s", res.State, res.FailurePhase)
156 }
157 if res.ExitCode != nil {
158 t.Fatalf("exitCode should be nil for launch failure, got %v", *res.ExitCode)
159 }
160 }
161
162 func TestRunForegroundOutputTailBounded(t *testing.T) {
163 payload := strings.Repeat("中文", 3000)
164 // Keep the command under typical argv length limits.
165 if len(payload) > 4000 {
166 payload = payload[:4000]
167 }
168 sh := sandbox.ResolveShell("auto", "", nil)
169 var command string
170 if sh.Kind == sandbox.ShellPowerShell {
171 command = `[Console]::Error.Write('` + strings.ReplaceAll(payload, "'", "''") + `')`
172 } else {
173 command = "printf '%s' '" + strings.ReplaceAll(payload, "'", `'\"'\"'`) + "' 1>&2"
174 }
175 argv := shellArgvWith(sh, command)
176 res := RunForeground(context.Background(), Request{
177 Argv: argv,
178 ShellKind: sh.Kind.String(),
179 ShellPath: sh.Path,
180 Track: true,
181 })
182 if len(res.OutputTail) > tool.OutputTailMaxBytes {
183 t.Fatalf("output tail %d > %d", len(res.OutputTail), tool.OutputTailMaxBytes)
184 }
185 if !strings.Contains(res.Combined, "中文") && !strings.Contains(res.OutputTail, "中文") {
186 t.Fatalf("UTF-8 Chinese lost: combined=%q tail=%q", trim(res.Combined, 80), trim(res.OutputTail, 80))
187 }
188 }
189
190 // TestRunForegroundSharesOnePipeForStdoutAndStderr pins the mechanism behind
191 // ordered combined output: os/exec reuses a single pipe and a single copy
192 // goroutine only while Stdout and Stderr hold the same writer value. Giving them
193 // two writers (for example to tee stderr into its own tail) silently splits the
194 // child's streams into two pipes, and the model then reads reordered output.
195 func TestRunForegroundSharesOnePipeForStdoutAndStderr(t *testing.T) {
196 var captured *exec.Cmd
197 RunForeground(context.Background(), Request{
198 Argv: []string{"irrelevant"},
199 Progress: func(string) {},
200 Run: func(_ context.Context, cmd *exec.Cmd, _ proc.RunOptions) (*proc.TrackedCommand, error) {
201 captured = cmd
202 return nil, nil
203 },
204 })
205 if captured == nil {
206 t.Fatal("runner never built a command")
207 }
208 if captured.Stdout == nil || captured.Stdout != captured.Stderr {
209 t.Fatalf("Stdout and Stderr must be the same writer value; got %p and %p", captured.Stdout, captured.Stderr)
210 }
211 }
212
213 // TestRunForegroundPreservesInterleaving is the behavioral half of the same
214 // contract: what the child wrote first must still come first.
215 func TestRunForegroundPreservesInterleaving(t *testing.T) {
216 sh := sandbox.ResolveShell("auto", "", nil)
217 if sh.Kind == sandbox.ShellPowerShell {
218 t.Skip("stream-buffering semantics differ on PowerShell; the pipe-identity test covers the mechanism")
219 }
220 const rounds = 8
221 var want strings.Builder
222 for i := 1; i <= rounds; i++ {
223 fmt.Fprintf(&want, "out%d\nerr%d\n", i, i)
224 }
225 argv := shellArgvWith(sh, "for i in 1 2 3 4 5 6 7 8; do echo out$i; echo err$i 1>&2; done")
226 // Repeat: two pipes reorder probabilistically, so one run can pass by luck.
227 for run := 0; run < 10; run++ {
228 res := RunForeground(context.Background(), Request{Argv: argv, Timeout: 30 * time.Second})
229 if res.Combined != want.String() {
230 t.Fatalf("run %d lost child write order:\ngot %q\nwant %q", run, res.Combined, want.String())
231 }
232 }
233 }
234
235 // TestRunForegroundDropsTailOnSuccess keeps a successful command from carrying
236 // up to 16 KiB of ordinary stdout into the session record and the tool card.
237 func TestRunForegroundDropsTailOnSuccess(t *testing.T) {
238 argv, _ := shellArgv(t, "echo hello")
239 res := RunForeground(context.Background(), Request{Argv: argv, Timeout: 30 * time.Second})
240 if res.State != tool.ShellStateCompleted {
241 t.Fatalf("State = %q, want %q", res.State, tool.ShellStateCompleted)
242 }
243 if !strings.Contains(res.Combined, "hello") {
244 t.Fatalf("Combined = %q, want it to contain the output", res.Combined)
245 }
246 if res.OutputTail != "" {
247 t.Fatalf("OutputTail = %q, want empty on success", res.OutputTail)
248 }
249 }
250
251 func shellArgv(t *testing.T, command string) ([]string, sandbox.Shell) {
252 t.Helper()
253 sh := sandbox.ResolveShell("auto", "", nil)
254 return shellArgvWith(sh, command), sh
255 }
256
257 func shellArgvWith(sh sandbox.Shell, command string) []string {
258 path := sh.Path
259 if path == "" {
260 path = sh.Kind.String()
261 }
262 if sh.Kind == sandbox.ShellPowerShell {
263 return []string{path, "-NoProfile", "-NonInteractive", "-Command", sandbox.PowerShellUTF8Script(command)}
264 }
265 return []string{path, "-c", command}
266 }
267
268 func trim(s string, n int) string {
269 if len(s) <= n {
270 return s
271 }
272 return s[:n]
273 }
274
274 lines GO