返回 DeepSeek-Reasonix
windows_batch_test.go
根目录 / internal / hook / windows_batch_test.go
1 //go:build windows
2
3 package hook
4
5 import (
6 "context"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "reasonix/internal/pluginpkg"
14 "reasonix/internal/sandbox"
15 )
16
17 func TestDefaultSpawnerRunsQuotedPluginBatchHook(t *testing.T) {
18 pluginRoot := filepath.Join(t.TempDir(), "plugin root")
19 hooksDir := filepath.Join(pluginRoot, "hooks")
20 if err := os.MkdirAll(hooksDir, 0o755); err != nil {
21 t.Fatal(err)
22 }
23 script := filepath.Join(hooksDir, "run-hook.cmd")
24 // Use raw %1 rather than %~1 so the test catches accidental argument
25 // re-quoting; %~1 would hide added surrounding quotes.
26 contents := "@echo off\r\nset /p hook_input=\r\necho %1:%hook_input%\r\n"
27 if err := os.WriteFile(script, []byte(contents), 0o644); err != nil {
28 t.Fatal(err)
29 }
30
31 for _, tt := range []struct {
32 name string
33 command string
34 args []string
35 mode ExecutionMode
36 }{
37 {name: "shell form", command: `"` + filepath.ToSlash(script) + `" session-start`},
38 {name: "explicit default shell form", command: `"` + filepath.ToSlash(script) + `" session-start`, mode: ExecutionShell},
39 {name: "argv form", command: filepath.ToSlash(script), args: []string{"session-start"}},
40 } {
41 t.Run(tt.name, func(t *testing.T) {
42 result := DefaultSpawner(context.Background(), SpawnInput{
43 Command: tt.command,
44 Args: tt.args,
45 Mode: tt.mode,
46 Stdin: `{"event":"SessionStart"}`,
47 Timeout: realSpawnTimeout,
48 })
49 if result.ExitCode != 0 || result.SpawnErr != nil {
50 t.Fatalf("batch hook failed: %+v", result)
51 }
52 if got, want := result.Stdout, `session-start:{"event":"SessionStart"}`; got != want {
53 t.Fatalf("batch hook stdout = %q, want %q", got, want)
54 }
55 })
56 }
57 }
58
59 func TestSuperpowersV611SessionStartHookEndToEnd(t *testing.T) {
60 home := t.TempDir()
61 installSuperpowersV611HookFixture(t, home)
62 workspace := filepath.Join(home, "workspace")
63 hooks := Load(LoadOptions{HomeDir: home, ProjectRoot: workspace})
64 if len(hooks) != 1 {
65 t.Fatalf("hooks = %+v, want one superpowers hook", hooks)
66 }
67
68 report := Run(context.Background(), Payload{
69 Event: SessionStart,
70 SessionID: "issue-6602",
71 Cwd: workspace,
72 }, hooks, nil)
73 if report.Blocked || len(report.Outcomes) != 1 {
74 t.Fatalf("SessionStart report = %+v", report)
75 }
76 outcome := report.Outcomes[0]
77 if outcome.Decision != DecisionPass || outcome.ExitCode != 0 || outcome.Stderr != "" || outcome.TimedOut {
78 t.Fatalf("SessionStart outcome = %+v", outcome)
79 }
80 if !strings.HasPrefix(outcome.Stdout, "session-start:") ||
81 !strings.Contains(outcome.Stdout, `"hook_event_name":"SessionStart"`) ||
82 !strings.Contains(outcome.Stdout, `"session_id":"issue-6602"`) {
83 t.Fatalf("SessionStart stdout = %q, want batch argument plus Claude-compatible stdin", outcome.Stdout)
84 }
85 }
86
87 func TestExtensionlessSessionStartHookUsesAutoResolvedBash(t *testing.T) {
88 shell := sandbox.ResolveShell("auto", "", nil)
89 if shell.Kind != sandbox.ShellBash || strings.TrimSpace(shell.Path) == "" {
90 t.Skip("Git Bash is not available on this Windows test host")
91 }
92
93 home := t.TempDir()
94 reasonixHome := filepath.Join(home, ".reasonix")
95 root := filepath.Join(reasonixHome, "plugins", "superpowers")
96 writeHookTestFile(t, filepath.Join(root, pluginpkg.CodexManifest), `{
97 "name": "superpowers",
98 "version": "6.1.1"
99 }`)
100 writeHookTestFile(t, filepath.Join(root, "hooks", "session-start-codex"), `#!/usr/bin/env bash
101 input=$(cat)
102 printf 'session-start:%s' "$input"
103 `)
104 if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
105 Name: "superpowers",
106 Root: "plugins/superpowers",
107 Version: "6.1.1",
108 ManifestKind: "codex",
109 Enabled: true,
110 }); err != nil {
111 t.Fatal(err)
112 }
113
114 workspace := filepath.Join(home, "workspace")
115 hooks := Load(LoadOptions{HomeDir: home, ProjectRoot: workspace})
116 if len(hooks) != 1 {
117 t.Fatalf("hooks = %+v, want one extensionless SessionStart hook", hooks)
118 }
119 if hooks[0].ExecutionMode != ExecutionShell || hooks[0].Shell != "bash" {
120 t.Fatalf("hook execution contract = mode %q shell %q, want auto-resolved Bash", hooks[0].ExecutionMode, hooks[0].Shell)
121 }
122
123 report := Run(context.Background(), Payload{
124 Event: SessionStart,
125 SessionID: "auto-bash",
126 Cwd: workspace,
127 }, hooks, NewDefaultSpawner(RuntimeOptions{BashPath: shell.Path}))
128 if report.Blocked || len(report.Outcomes) != 1 {
129 t.Fatalf("SessionStart report = %+v", report)
130 }
131 outcome := report.Outcomes[0]
132 if outcome.Decision != DecisionPass || outcome.ExitCode != 0 || outcome.Stderr != "" || outcome.TimedOut {
133 t.Fatalf("SessionStart outcome = %+v", outcome)
134 }
135 if !strings.HasPrefix(outcome.Stdout, "session-start:") || !strings.Contains(outcome.Stdout, `"event":"SessionStart"`) {
136 t.Fatalf("SessionStart stdout = %q, want native JSON payload", outcome.Stdout)
137 }
138 }
139
140 func TestProjectAndGlobalExtensionlessHooksUseAutoResolvedBash(t *testing.T) {
141 shell := sandbox.ResolveShell("auto", "", nil)
142 if shell.Kind != sandbox.ShellBash || strings.TrimSpace(shell.Path) == "" {
143 t.Skip("Git Bash is not available on this Windows test host")
144 }
145
146 home := t.TempDir()
147 workspace := filepath.Join(home, "workspace")
148 projectScript := filepath.Join(workspace, "hooks", "project-start")
149 globalScript := filepath.Join(workspace, "hooks", "global-start")
150 for _, item := range []struct {
151 path string
152 prefix string
153 }{
154 {path: projectScript, prefix: "project"},
155 {path: globalScript, prefix: "global"},
156 } {
157 writeHookTestFile(t, item.path, "#!/usr/bin/env bash\ninput=$(cat)\nprintf '"+item.prefix+":%s' \"$input\"\n")
158 }
159 writeHookTestFile(t, filepath.Join(workspace, ".reasonix", "settings.json"), `{"hooks":{"SessionStart":[{"command":"hooks/project-start"}]}}`)
160 reasonixHome := filepath.Join(home, ".reasonix")
161 writeHookTestFile(t, filepath.Join(reasonixHome, "settings.json"), `{"hooks":{"SessionStart":[{"command":"hooks/global-start"}]}}`)
162
163 hooks := Load(LoadOptions{HomeDir: home, ProjectRoot: workspace})
164 if len(hooks) != 2 {
165 t.Fatalf("hooks = %+v, want project and global hooks", hooks)
166 }
167 report := Run(context.Background(), Payload{
168 Event: SessionStart,
169 SessionID: "settings-bash",
170 Cwd: workspace,
171 }, hooks, NewDefaultSpawner(RuntimeOptions{BashPath: shell.Path}))
172 if report.Blocked || len(report.Outcomes) != 2 {
173 t.Fatalf("SessionStart report = %+v", report)
174 }
175 for i, wantPrefix := range []string{"project:", "global:"} {
176 outcome := report.Outcomes[i]
177 if outcome.Decision != DecisionPass || outcome.ExitCode != 0 || outcome.Stderr != "" || outcome.TimedOut {
178 t.Fatalf("SessionStart outcome[%d] = %+v", i, outcome)
179 }
180 if !strings.HasPrefix(outcome.Stdout, wantPrefix) || !strings.Contains(outcome.Stdout, `"event":"SessionStart"`) {
181 t.Fatalf("SessionStart stdout[%d] = %q, want %q plus native JSON payload", i, outcome.Stdout, wantPrefix)
182 }
183 }
184 }
185
186 func TestDefaultSpawnerRunsCompoundCmdShellHook(t *testing.T) {
187 pluginRoot := filepath.Join(t.TempDir(), "plugin root")
188 if err := os.MkdirAll(pluginRoot, 0o755); err != nil {
189 t.Fatal(err)
190 }
191 script := filepath.Join(pluginRoot, "compound-hook.cmd")
192 if err := os.WriteFile(script, []byte("@echo off\r\necho script:%1\r\n"), 0o644); err != nil {
193 t.Fatal(err)
194 }
195
196 result := DefaultSpawner(context.Background(), SpawnInput{
197 Command: `"` + filepath.ToSlash(script) + `" "argument with spaces" && echo chained`,
198 Mode: ExecutionShell,
199 Shell: "cmd",
200 Timeout: realSpawnTimeout,
201 })
202 if result.ExitCode != 0 || result.SpawnErr != nil {
203 t.Fatalf("compound cmd hook failed: %+v", result)
204 }
205 got := strings.ReplaceAll(result.Stdout, "\r\n", "\n")
206 // %1 preserves the quoting required to keep the spaced argument together.
207 // A batch script that wants the dequoted value uses %~1 instead.
208 if got != "script:\"argument with spaces\"\nchained" {
209 t.Fatalf("compound cmd stdout = %q", got)
210 }
211 }
212
213 func TestDefaultSpawnerRunsPowerShellHookWithNestedQuotes(t *testing.T) {
214 // Cold PowerShell start under the Windows full suite (-p 4) can exceed the
215 // default 60s real-spawn budget when the runner is already hot from
216 // agent/boot/control packages. Keep the assertion, give the host longer.
217 timeout := max(realSpawnTimeout, 2*time.Minute)
218 result := DefaultSpawner(context.Background(), SpawnInput{
219 Command: `$items = @("a b", "c'd", 'e"f', "中文", "🧪"); Write-Output ($items -join "|")`,
220 Mode: ExecutionShell,
221 Shell: "powershell",
222 Timeout: timeout,
223 })
224 if result.ExitCode != 0 || result.SpawnErr != nil {
225 t.Fatalf("PowerShell hook failed: %+v", result)
226 }
227 if got, want := result.Stdout, `a b|c'd|e"f|中文|🧪`; got != want {
228 t.Fatalf("PowerShell stdout = %q, want %q", got, want)
229 }
230 }
231
232 func TestDefaultSpawnerCmdShellExpandsEnvironmentAndPipes(t *testing.T) {
233 result := DefaultSpawner(context.Background(), SpawnInput{
234 Command: `echo %HOOK_CMD_VALUE% | findstr cmd-ok`,
235 Mode: ExecutionShell,
236 Shell: "cmd",
237 Env: map[string]string{"HOOK_CMD_VALUE": "cmd-ok"},
238 Timeout: realSpawnTimeout,
239 })
240 if result.ExitCode != 0 || result.SpawnErr != nil || !strings.Contains(result.Stdout, "cmd-ok") {
241 t.Fatalf("cmd shell hook failed: %+v", result)
242 }
243 }
244
244 lines GO