返回 DeepSeek-Reasonix
bash_powershell_test.go
根目录 / internal / tool / builtin / bash_powershell_test.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "runtime"
10 "strings"
11 "testing"
12 "unicode/utf8"
13
14 "reasonix/internal/sandbox"
15 "reasonix/internal/tool"
16 )
17
18 func powershellPath(t *testing.T) string {
19 t.Helper()
20 for _, n := range []string{"pwsh", "powershell"} {
21 if p, err := exec.LookPath(n); err == nil {
22 return p
23 }
24 }
25 t.Skip("no PowerShell on PATH")
26 return ""
27 }
28
29 func runPS(t *testing.T, command string) (string, error) {
30 t.Helper()
31 b := bash{shell: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: powershellPath(t)}}
32 args, _ := json.Marshal(map[string]string{"command": command})
33 return b.Execute(context.Background(), args)
34 }
35
36 func TestBashPowerShellRunsNativeCommand(t *testing.T) {
37 if runtime.GOOS != "windows" {
38 t.Skip("powershell e2e is windows-only")
39 }
40 out, err := runPS(t, "Write-Output reasonix-ok")
41 if err != nil {
42 t.Fatalf("powershell command failed: %v (out=%q)", err, out)
43 }
44 if !strings.Contains(out, "reasonix-ok") {
45 t.Fatalf("output = %q, want it to contain reasonix-ok", out)
46 }
47 }
48
49 func TestBashPowerShellSurfacesNonZeroExit(t *testing.T) {
50 if runtime.GOOS != "windows" {
51 t.Skip("powershell e2e is windows-only")
52 }
53 if _, err := runPS(t, "exit 3"); err == nil {
54 t.Fatal("non-zero exit should surface as an error")
55 }
56 }
57
58 func TestBashPowerShellRejectsChaining(t *testing.T) {
59 b := bash{shell: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "powershell"}}
60 for _, cmd := range []string{"echo a && echo b", "echo a || echo b"} {
61 args, _ := json.Marshal(map[string]string{"command": cmd})
62 out, err := b.Execute(context.Background(), args)
63 if err == nil {
64 t.Errorf("%q should be rejected on powershell, got out=%q", cmd, out)
65 } else if !strings.Contains(err.Error(), "PowerShell") {
66 t.Errorf("%q error should explain PowerShell, got %v", cmd, err)
67 }
68 }
69 }
70
71 func TestBashPowerShellAllowsQuotedOperator(t *testing.T) {
72 if runtime.GOOS != "windows" {
73 t.Skip("runs a real powershell command")
74 }
75 // "&&" inside a string literal is data, not chaining — must not be rejected.
76 out, err := runPS(t, `Write-Output "a && b"`)
77 if err != nil {
78 t.Fatalf("quoted && should run: %v (out=%q)", err, out)
79 }
80 if !strings.Contains(out, "a && b") {
81 t.Fatalf("output = %q", out)
82 }
83 }
84
85 func TestBashPwshAllowsChaining(t *testing.T) {
86 // pwsh (PowerShell 7+) parses && — the guard must not block it.
87 b := bash{shell: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "pwsh"}}
88 args, _ := json.Marshal(map[string]string{"command": "echo a && echo b"})
89 _, err := b.Execute(context.Background(), args)
90 if err != nil && strings.Contains(err.Error(), "does not parse") {
91 t.Errorf("pwsh should not be blocked by the chaining guard: %v", err)
92 }
93 }
94
95 func TestBashPowerShellOutputIsUTF8(t *testing.T) {
96 if runtime.GOOS != "windows" {
97 t.Skip("powershell e2e is windows-only")
98 }
99 out, err := runPS(t, "Write-Output 'AB-中文-CD'")
100 if err != nil {
101 t.Fatalf("command failed: %v (out=%q)", err, out)
102 }
103 if !strings.Contains(out, "中文") {
104 t.Fatalf("non-ASCII output mojibake — got %q (want it to contain 中文)", out)
105 }
106 }
107
108 // TestBashPowerShellExecuteDetailedContract is the Windows CI contract for the
109 // shell execution metadata path: Chinese workspace path, UTF-8 output, exit
110 // code preservation (including 0), and PowerShell 5.1 vs pwsh identity.
111 func TestBashPowerShellExecuteDetailedContract(t *testing.T) {
112 if runtime.GOOS != "windows" {
113 t.Skip("powershell e2e is windows-only; Linux/macOS CI covers bash path")
114 }
115 // Windows PowerShell 5.1 is the compatibility-critical path: unlike pwsh,
116 // it does not parse &&/|| and commonly runs under a legacy console code page.
117 // Require it on native Windows instead of silently selecting pwsh first.
118 ps51Path, err := exec.LookPath("powershell")
119 if err != nil {
120 t.Fatalf("Windows PowerShell 5.1 is required for this contract: %v", err)
121 }
122 paths := []struct {
123 name string
124 path string
125 }{{name: "powershell-5.1", path: ps51Path}}
126 // PowerShell 7 is optional for ordinary Windows installations, but the
127 // GitHub Windows runner provides it; exercise it whenever available.
128 if pwshPath, lookupErr := exec.LookPath("pwsh"); lookupErr == nil {
129 paths = append(paths, struct {
130 name string
131 path string
132 }{name: "pwsh-7", path: pwshPath})
133 }
134 for _, tc := range paths {
135 t.Run(tc.name, func(t *testing.T) {
136 assertPowerShellDetailedContract(t, tc.path)
137 })
138 }
139 }
140
141 func assertPowerShellDetailedContract(t *testing.T, psPath string) {
142 t.Helper()
143 // Chinese directory name — native Windows CI must keep path + UTF-8 intact.
144 work := filepath.Join(t.TempDir(), "中文目录-reasonix")
145 if err := os.MkdirAll(work, 0o755); err != nil {
146 t.Fatal(err)
147 }
148 marker := filepath.Join(work, "标记.txt")
149 if err := os.WriteFile(marker, []byte("内容-utf8"), 0o644); err != nil {
150 t.Fatal(err)
151 }
152
153 b := bash{shell: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: psPath}, workDir: work}
154
155 // Success: exit 0 must be retained (not omitted) and UTF-8 content preserved.
156 argsOK, _ := json.Marshal(map[string]string{
157 "command": "Get-Content -LiteralPath .\\标记.txt -Encoding utf8; Write-Output '中文-ok'",
158 })
159 res, err := b.ExecuteDetailed(context.Background(), argsOK)
160 if err != nil {
161 t.Fatalf("success path: %v out=%q", err, res.Output)
162 }
163 if res.Execution == nil {
164 t.Fatal("missing execution metadata")
165 }
166 if res.Execution.State != tool.ShellStateCompleted {
167 t.Fatalf("state=%q", res.Execution.State)
168 }
169 if res.Execution.ExitCode == nil || *res.Execution.ExitCode != 0 {
170 t.Fatalf("exitCode=%v want 0", res.Execution.ExitCode)
171 }
172 if !strings.Contains(res.Output, "内容-utf8") || !strings.Contains(res.Output, "中文-ok") {
173 t.Fatalf("UTF-8/Chinese lost in combined output: %q", res.Output)
174 }
175 if !utf8.ValidString(res.Output) {
176 t.Fatal("combined output is not valid UTF-8")
177 }
178 // Descriptor identity: powershell.exe → 5.1; pwsh → 7+.
179 base := strings.ToLower(filepath.Base(psPath))
180 base = strings.TrimSuffix(base, ".exe")
181 if base == "pwsh" {
182 if res.Execution.Shell != tool.ShellNamePwsh || res.Execution.ShellVersion != tool.ShellVersionPS7 {
183 t.Fatalf("pwsh identity = %s/%s", res.Execution.Shell, res.Execution.ShellVersion)
184 }
185 if !res.Execution.SupportsAndAnd {
186 t.Fatal("pwsh should support &&")
187 }
188 } else {
189 if res.Execution.Shell != tool.ShellNamePowerShell || res.Execution.ShellVersion != tool.ShellVersionPS51 {
190 t.Fatalf("powershell identity = %s/%s", res.Execution.Shell, res.Execution.ShellVersion)
191 }
192 if res.Execution.SupportsAndAnd {
193 t.Fatal("Windows PowerShell 5.1 must not claim && support")
194 }
195 }
196
197 // Non-zero exit: preserve real code and execution failure phase.
198 argsFail, _ := json.Marshal(map[string]string{"command": "exit 17"})
199 fail, err := b.ExecuteDetailed(context.Background(), argsFail)
200 if err == nil {
201 t.Fatal("exit 17 should error")
202 }
203 if fail.Execution == nil || fail.Execution.ExitCode == nil || *fail.Execution.ExitCode != 17 {
204 t.Fatalf("exit metadata = %+v", fail.Execution)
205 }
206 if fail.Execution.State != tool.ShellStateFailed || fail.Execution.FailurePhase != tool.ShellPhaseExecution {
207 t.Fatalf("fail state/phase = %s/%s", fail.Execution.State, fail.Execution.FailurePhase)
208 }
209 }
210
211 func TestBashPowerShell51PreflightRejectsAndAndDetailed(t *testing.T) {
212 // Runs on every OS: pure preflight, no process launch.
213 b := bash{shell: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "powershell"}}
214 args, _ := json.Marshal(map[string]string{"command": "echo a && echo b"})
215 res, err := b.ExecuteDetailed(context.Background(), args)
216 if err == nil {
217 t.Fatal("expected preflight rejection")
218 }
219 if res.Execution == nil {
220 t.Fatal("missing execution")
221 }
222 if res.Execution.State != tool.ShellStateNotRun || res.Execution.FailurePhase != tool.ShellPhasePreflight {
223 t.Fatalf("state/phase = %s/%s", res.Execution.State, res.Execution.FailurePhase)
224 }
225 if res.Execution.MutationRisk != tool.ShellMutationNotStarted {
226 t.Fatalf("mutationRisk = %q", res.Execution.MutationRisk)
227 }
228 if res.Execution.ExitCode != nil {
229 t.Fatalf("exitCode must be unset for preflight, got %v", *res.Execution.ExitCode)
230 }
231 }
232
233 func TestBashDescriptionReflectsShell(t *testing.T) {
234 ps := bash{shell: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "powershell"}}
235 psDesc := ps.Description()
236 if !strings.Contains(psDesc, "Windows PowerShell") {
237 t.Errorf("powershell description should name Windows PowerShell: %q", psDesc)
238 }
239 if !strings.Contains(psDesc, "'&&' and '||' are NOT parsed") {
240 t.Errorf("powershell description should warn about unsupported chaining: %q", psDesc)
241 }
242 pwsh := bash{shell: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "pwsh"}}
243 pwshDesc := pwsh.Description()
244 if !strings.Contains(pwshDesc, "PowerShell 7 (pwsh)") {
245 t.Errorf("pwsh description should name PowerShell 7: %q", pwshDesc)
246 }
247 if !strings.Contains(pwshDesc, "'&&' and '||' are parsed") {
248 t.Errorf("pwsh description should allow conditional chaining: %q", pwshDesc)
249 }
250 if strings.Contains(pwshDesc, "NOT parsed") {
251 t.Errorf("pwsh description should not reuse the Windows PowerShell chaining warning: %q", pwshDesc)
252 }
253 sh := bash{shell: sandbox.Shell{Kind: sandbox.ShellBash, Path: "bash"}}
254 if strings.Contains(sh.Description(), "PowerShell") {
255 t.Errorf("bash description should not mention PowerShell: %q", sh.Description())
256 }
257 }
258
258 lines GO