| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "os/exec" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "reasonix/internal/event" |
| 13 | "reasonix/internal/i18n" |
| 14 | "reasonix/internal/sandbox" |
| 15 | ) |
| 16 | |
| 17 | // collectSink returns a Sink that collects events and a channel that receives |
| 18 | // the TurnDone event when the turn finishes. The channel lets tests wait for |
| 19 | // the runGuarded goroutine to complete. |
| 20 | func collectSink() (event.Sink, chan event.Event, *[]event.Event) { |
| 21 | var events []event.Event |
| 22 | done := make(chan event.Event, 1) |
| 23 | sink := event.FuncSink(func(e event.Event) { |
| 24 | events = append(events, e) |
| 25 | if e.Kind == event.TurnDone { |
| 26 | done <- e |
| 27 | } |
| 28 | }) |
| 29 | return sink, done, &events |
| 30 | } |
| 31 | |
| 32 | func waitForDone(t *testing.T, done chan event.Event) event.Event { |
| 33 | t.Helper() |
| 34 | return waitForDoneWithin(t, done, 5*time.Second) |
| 35 | } |
| 36 | |
| 37 | func waitForDoneWithin(t *testing.T, done chan event.Event, d time.Duration) event.Event { |
| 38 | t.Helper() |
| 39 | select { |
| 40 | case e := <-done: |
| 41 | return e |
| 42 | case <-time.After(d): |
| 43 | t.Fatal("timed out waiting for TurnDone") |
| 44 | return event.Event{} |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | func TestRunShell_EmitsEvents(t *testing.T) { |
| 49 | sink, done, events := collectSink() |
| 50 | ctrl := &Controller{sink: sink} |
| 51 | |
| 52 | ctrl.RunShell("echo hello") |
| 53 | waitForDone(t, done) |
| 54 | |
| 55 | if len(*events) < 3 { |
| 56 | t.Fatalf("expected at least 3 events, got %d: %v", len(*events), *events) |
| 57 | } |
| 58 | |
| 59 | // First event: ToolDispatch |
| 60 | if (*events)[0].Kind != event.ToolDispatch { |
| 61 | t.Errorf("first event: want ToolDispatch, got %v", (*events)[0].Kind) |
| 62 | } |
| 63 | if (*events)[0].Tool.Name != "bash" { |
| 64 | t.Errorf("tool name: want bash, got %s", (*events)[0].Tool.Name) |
| 65 | } |
| 66 | |
| 67 | // Last event: TurnDone |
| 68 | td := (*events)[len(*events)-1] |
| 69 | if td.Kind != event.TurnDone { |
| 70 | t.Errorf("last event: want TurnDone, got %v", td.Kind) |
| 71 | } |
| 72 | |
| 73 | // Penultimate event: ToolResult |
| 74 | last := (*events)[len(*events)-2] |
| 75 | if last.Kind != event.ToolResult { |
| 76 | t.Errorf("penultimate event: want ToolResult, got %v", last.Kind) |
| 77 | } |
| 78 | if last.Tool.Err != "" { |
| 79 | t.Errorf("unexpected error: %s", last.Tool.Err) |
| 80 | } |
| 81 | if !strings.Contains(last.Tool.Output, "hello") { |
| 82 | t.Errorf("output should contain 'hello', got: %s", last.Tool.Output) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | func TestSubmit_BangPrefix(t *testing.T) { |
| 87 | sink, done, events := collectSink() |
| 88 | ctrl := &Controller{sink: sink} |
| 89 | |
| 90 | ctrl.Submit("!echo test") |
| 91 | waitForDone(t, done) |
| 92 | |
| 93 | if len(*events) == 0 { |
| 94 | t.Fatal("expected events from !echo, got none") |
| 95 | } |
| 96 | if (*events)[0].Kind != event.ToolDispatch { |
| 97 | t.Errorf("first event: want ToolDispatch, got %v", (*events)[0].Kind) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | func TestSubmit_BangEmpty(t *testing.T) { |
| 102 | var notices []string |
| 103 | sink := event.FuncSink(func(e event.Event) { |
| 104 | if e.Kind == event.Notice { |
| 105 | notices = append(notices, e.Text) |
| 106 | } |
| 107 | }) |
| 108 | |
| 109 | ctrl := &Controller{sink: sink} |
| 110 | ctrl.Submit("!") |
| 111 | |
| 112 | if len(notices) == 0 { |
| 113 | t.Fatal("expected a notice for bare !") |
| 114 | } |
| 115 | if !strings.Contains(notices[0], "!") { |
| 116 | t.Errorf("notice should mention usage, got: %s", notices[0]) |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | func TestSubmit_BangNotFirstChar(t *testing.T) { |
| 121 | // "! " not at position 0 should NOT trigger shell. Submit routes to |
| 122 | // runRefTurn for normal text, which needs a runner — so we test the |
| 123 | // prefix-check condition directly. |
| 124 | input := "tell me about !important" |
| 125 | trimmed := strings.TrimSpace(input) |
| 126 | if strings.HasPrefix(trimmed, "!") { |
| 127 | t.Error("trimmed input should not start with !") |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | func TestRunShell_FailingCommand(t *testing.T) { |
| 132 | sink, done, events := collectSink() |
| 133 | ctrl := &Controller{sink: sink} |
| 134 | |
| 135 | ctrl.RunShell("false") // exits 1 |
| 136 | waitForDone(t, done) |
| 137 | |
| 138 | // Find the ToolResult |
| 139 | var result *event.Event |
| 140 | for i := range *events { |
| 141 | if (*events)[i].Kind == event.ToolResult { |
| 142 | result = &(*events)[i] |
| 143 | break |
| 144 | } |
| 145 | } |
| 146 | if result == nil { |
| 147 | t.Fatal("expected a ToolResult event") |
| 148 | } else if result.Tool.Err == "" { |
| 149 | t.Error("failing command should produce an error string") |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | func TestRunShell_CancelStopsCommand(t *testing.T) { |
| 154 | sink, done, events := collectSink() |
| 155 | ctrl := &Controller{sink: sink} |
| 156 | |
| 157 | command := "sleep 30" |
| 158 | if sandbox.ResolveShell("", "", nil).Kind == sandbox.ShellPowerShell { |
| 159 | command = "Start-Sleep -Seconds 30" |
| 160 | } |
| 161 | ctrl.RunShell(command) |
| 162 | time.Sleep(100 * time.Millisecond) |
| 163 | ctrl.Cancel() |
| 164 | |
| 165 | // Cancel kills the shell via the run context, but cmd.Wait honours |
| 166 | // shellWaitDelay (and on Windows cmd.Cancel spawns taskkill /F /T), so |
| 167 | // TurnDone can arrive almost a full shellWaitDelay after Cancel. Wait |
| 168 | // comfortably longer than that grace — a flat 5s budget equalled |
| 169 | // shellWaitDelay and lost the race on a loaded windows runner. |
| 170 | e := waitForDoneWithin(t, done, shellWaitDelay+10*time.Second) |
| 171 | if e.Kind != event.TurnDone { |
| 172 | t.Fatalf("done event kind = %v, want TurnDone", e.Kind) |
| 173 | } |
| 174 | if e.Err != nil { |
| 175 | t.Fatalf("cancelled shell TurnDone err = %v, want nil", e.Err) |
| 176 | } |
| 177 | var result *event.Event |
| 178 | for i := range *events { |
| 179 | if (*events)[i].Kind == event.ToolResult { |
| 180 | result = &(*events)[i] |
| 181 | break |
| 182 | } |
| 183 | } |
| 184 | if result == nil { |
| 185 | t.Fatal("expected ToolResult for cancelled shell") |
| 186 | } |
| 187 | if result.Tool.Err != i18n.M.TurnCancelled { |
| 188 | t.Fatalf("cancelled shell result err = %q, want %q", result.Tool.Err, i18n.M.TurnCancelled) |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | func TestRunShell_HeredocCancelReleasesTurn(t *testing.T) { |
| 193 | sh := requireRunShellHereDocBash(t) |
| 194 | sink, done, events := collectSink() |
| 195 | root := t.TempDir() |
| 196 | target := filepath.Join(root, "test_redact.go") |
| 197 | ctrl := &Controller{sink: sink, shell: sh, workspaceRoot: root} |
| 198 | |
| 199 | command := strings.Join([]string{ |
| 200 | "cat > " + controlShellQuote(filepath.ToSlash(target)) + " <<'EOF'", |
| 201 | "package main", |
| 202 | "", |
| 203 | "import (", |
| 204 | "\t\"encoding/json\"", |
| 205 | "\t\"fmt\"", |
| 206 | ")", |
| 207 | "", |
| 208 | "func main() {", |
| 209 | "\tdata := []byte(`{\"accounts\":[{\"id\":\"a1\",\"username\":\"alice\",\"token\":\"TOKEN_EXAMPLE\"}]}`)", |
| 210 | "\tvar v any", |
| 211 | "\tjson.Unmarshal(data, &v)", |
| 212 | "\tfmt.Printf(\"before: %v\\n\", v)", |
| 213 | "}", |
| 214 | "EOF", |
| 215 | "sleep 30", |
| 216 | }, "\n") |
| 217 | |
| 218 | ctrl.RunShell(command) |
| 219 | if !waitForFileContainingWithin(target, "TOKEN_EXAMPLE", 2*time.Second) { |
| 220 | ctrl.Cancel() |
| 221 | waitForDoneWithin(t, done, shellWaitDelay+10*time.Second) |
| 222 | t.Fatalf("heredoc target body was not written before cancel: %s", target) |
| 223 | } |
| 224 | ctrl.Cancel() |
| 225 | |
| 226 | e := waitForDoneWithin(t, done, shellWaitDelay+10*time.Second) |
| 227 | if e.Kind != event.TurnDone { |
| 228 | t.Fatalf("done event kind = %v, want TurnDone", e.Kind) |
| 229 | } |
| 230 | if e.Err != nil { |
| 231 | t.Fatalf("cancelled heredoc shell TurnDone err = %v, want nil", e.Err) |
| 232 | } |
| 233 | var result *event.Event |
| 234 | for i := range *events { |
| 235 | if (*events)[i].Kind == event.ToolResult { |
| 236 | result = &(*events)[i] |
| 237 | break |
| 238 | } |
| 239 | } |
| 240 | if result == nil { |
| 241 | t.Fatal("expected ToolResult for cancelled heredoc shell") |
| 242 | } |
| 243 | if result.Tool.Err != i18n.M.TurnCancelled { |
| 244 | t.Fatalf("cancelled heredoc shell result err = %q, want %q", result.Tool.Err, i18n.M.TurnCancelled) |
| 245 | } |
| 246 | data, err := os.ReadFile(target) |
| 247 | if err != nil { |
| 248 | t.Fatalf("read heredoc target: %v", err) |
| 249 | } |
| 250 | if !strings.Contains(string(data), "TOKEN_EXAMPLE") { |
| 251 | t.Fatalf("heredoc target missing expected body:\n%s", data) |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | func requireRunShellHereDocBash(t *testing.T) sandbox.Shell { |
| 256 | t.Helper() |
| 257 | sh := sandbox.ResolveShell("bash", "", nil) |
| 258 | if sh.Kind != sandbox.ShellBash { |
| 259 | t.Skipf("bash heredoc regression requires bash, got %s", sh.Kind.String()) |
| 260 | } |
| 261 | path := sh.Path |
| 262 | if path == "" { |
| 263 | path = "bash" |
| 264 | } |
| 265 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) |
| 266 | defer cancel() |
| 267 | if err := exec.CommandContext(ctx, path, "-c", "true").Run(); err != nil { |
| 268 | t.Skipf("bash heredoc regression requires a runnable bash: %v", err) |
| 269 | } |
| 270 | sh.Path = path |
| 271 | return sh |
| 272 | } |
| 273 | |
| 274 | func waitForFileContainingWithin(path, want string, d time.Duration) bool { |
| 275 | deadline := time.Now().Add(d) |
| 276 | for time.Now().Before(deadline) { |
| 277 | if data, err := os.ReadFile(path); err == nil && strings.Contains(string(data), want) { |
| 278 | return true |
| 279 | } |
| 280 | time.Sleep(20 * time.Millisecond) |
| 281 | } |
| 282 | return false |
| 283 | } |
| 284 | |
| 285 | func controlShellQuote(s string) string { |
| 286 | return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" |
| 287 | } |
| 288 |