blob: fa591b27429578813aef07d68cf05b5ee10922cc [file] [log] [blame]
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001package server_test
2
3import (
4 "bufio"
5 "context"
Philip Zeyliger254c49f2025-07-17 17:26:24 -07006 "io"
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00007 "net/http"
8 "net/http/httptest"
9 "slices"
10 "strings"
11 "sync"
12 "testing"
13 "time"
14
15 "sketch.dev/llm/conversation"
16 "sketch.dev/loop"
17 "sketch.dev/loop/server"
Philip Zeyliger5f26a342025-07-04 01:30:29 +000018 "tailscale.com/portlist"
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000019)
20
21// mockAgent is a mock implementation of loop.CodingAgent for testing
22type mockAgent struct {
Philip Zeyligereab12de2025-05-14 02:35:53 +000023 mu sync.RWMutex
24 messages []loop.AgentMessage
25 messageCount int
26 currentState string
27 subscribers []chan *loop.AgentMessage
28 stateTransitionListeners []chan loop.StateTransition
bankseancad67b02025-06-27 21:57:05 +000029 gitUsername string
Philip Zeyligereab12de2025-05-14 02:35:53 +000030 initialCommit string
Philip Zeyligereab12de2025-05-14 02:35:53 +000031 branchName string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000032 branchPrefix string
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000033 workingDir string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070034 sessionID string
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -070035 slug string
36 retryNumber int
Philip Zeyliger0113be52025-06-07 23:53:41 +000037 skabandAddr string
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +000038 model string
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000039}
40
banksean5ab8fb82025-07-09 12:34:55 -070041// TokenContextWindow implements loop.CodingAgent.
42func (m *mockAgent) TokenContextWindow() int {
43 return 200000
44}
45
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +000046// ModelName implements loop.CodingAgent.
47func (m *mockAgent) ModelName() string {
48 return m.model
49}
50
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000051func (m *mockAgent) NewIterator(ctx context.Context, nextMessageIdx int) loop.MessageIterator {
52 m.mu.RLock()
53 // Send existing messages that should be available immediately
54 ch := make(chan *loop.AgentMessage, 100)
55 iter := &mockIterator{
56 agent: m,
57 ctx: ctx,
58 nextMessageIdx: nextMessageIdx,
59 ch: ch,
60 }
61 m.mu.RUnlock()
62 return iter
63}
64
65type mockIterator struct {
66 agent *mockAgent
67 ctx context.Context
68 nextMessageIdx int
69 ch chan *loop.AgentMessage
70 subscribed bool
71}
72
73func (m *mockIterator) Next() *loop.AgentMessage {
74 if !m.subscribed {
75 m.agent.mu.Lock()
76 m.agent.subscribers = append(m.agent.subscribers, m.ch)
77 m.agent.mu.Unlock()
78 m.subscribed = true
79 }
80
81 for {
82 select {
83 case <-m.ctx.Done():
84 return nil
85 case msg := <-m.ch:
86 return msg
87 }
88 }
89}
90
91func (m *mockIterator) Close() {
92 // Remove from subscribers using slices.Delete
93 m.agent.mu.Lock()
94 for i, ch := range m.agent.subscribers {
95 if ch == m.ch {
96 m.agent.subscribers = slices.Delete(m.agent.subscribers, i, i+1)
97 break
98 }
99 }
100 m.agent.mu.Unlock()
101 close(m.ch)
102}
103
104func (m *mockAgent) Messages(start int, end int) []loop.AgentMessage {
105 m.mu.RLock()
106 defer m.mu.RUnlock()
107
108 if start >= len(m.messages) || end > len(m.messages) || start < 0 || end < 0 {
109 return []loop.AgentMessage{}
110 }
111 return slices.Clone(m.messages[start:end])
112}
113
114func (m *mockAgent) MessageCount() int {
115 m.mu.RLock()
116 defer m.mu.RUnlock()
117 return m.messageCount
118}
119
120func (m *mockAgent) AddMessage(msg loop.AgentMessage) {
121 m.mu.Lock()
122 msg.Idx = m.messageCount
123 m.messages = append(m.messages, msg)
124 m.messageCount++
125
126 // Create a copy of subscribers to avoid holding the lock while sending
127 subscribers := make([]chan *loop.AgentMessage, len(m.subscribers))
128 copy(subscribers, m.subscribers)
129 m.mu.Unlock()
130
131 // Notify subscribers
132 msgCopy := msg // Create a copy to avoid race conditions
133 for _, ch := range subscribers {
134 ch <- &msgCopy
135 }
136}
137
Philip Zeyligereab12de2025-05-14 02:35:53 +0000138func (m *mockAgent) NewStateTransitionIterator(ctx context.Context) loop.StateTransitionIterator {
139 m.mu.Lock()
140 ch := make(chan loop.StateTransition, 10)
141 m.stateTransitionListeners = append(m.stateTransitionListeners, ch)
142 m.mu.Unlock()
143
144 return &mockStateTransitionIterator{
145 agent: m,
146 ctx: ctx,
147 ch: ch,
148 }
149}
150
151type mockStateTransitionIterator struct {
152 agent *mockAgent
153 ctx context.Context
154 ch chan loop.StateTransition
155}
156
157func (m *mockStateTransitionIterator) Next() *loop.StateTransition {
158 select {
159 case <-m.ctx.Done():
160 return nil
161 case transition, ok := <-m.ch:
162 if !ok {
163 return nil
164 }
165 transitionCopy := transition
166 return &transitionCopy
167 }
168}
169
170func (m *mockStateTransitionIterator) Close() {
171 m.agent.mu.Lock()
172 for i, ch := range m.agent.stateTransitionListeners {
173 if ch == m.ch {
174 m.agent.stateTransitionListeners = slices.Delete(m.agent.stateTransitionListeners, i, i+1)
175 break
176 }
177 }
178 m.agent.mu.Unlock()
179 close(m.ch)
180}
181
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000182func (m *mockAgent) CurrentStateName() string {
183 m.mu.RLock()
184 defer m.mu.RUnlock()
185 return m.currentState
186}
187
Philip Zeyligereab12de2025-05-14 02:35:53 +0000188func (m *mockAgent) TriggerStateTransition(from, to loop.State, event loop.TransitionEvent) {
189 m.mu.Lock()
190 m.currentState = to.String()
191 transition := loop.StateTransition{
192 From: from,
193 To: to,
194 Event: event,
195 }
196
197 // Create a copy of listeners to avoid holding the lock while sending
198 listeners := make([]chan loop.StateTransition, len(m.stateTransitionListeners))
199 copy(listeners, m.stateTransitionListeners)
200 m.mu.Unlock()
201
202 // Notify listeners
203 for _, ch := range listeners {
204 ch <- transition
205 }
206}
207
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000208func (m *mockAgent) InitialCommit() string {
209 m.mu.RLock()
210 defer m.mu.RUnlock()
211 return m.initialCommit
212}
213
Philip Zeyliger49edc922025-05-14 09:45:45 -0700214func (m *mockAgent) SketchGitBase() string {
215 m.mu.RLock()
216 defer m.mu.RUnlock()
217 return m.initialCommit
218}
219
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000220func (m *mockAgent) SketchGitBaseRef() string {
221 return "sketch-base-test-session"
222}
223
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000224func (m *mockAgent) BranchName() string {
225 m.mu.RLock()
226 defer m.mu.RUnlock()
227 return m.branchName
228}
229
230// Other required methods of loop.CodingAgent with minimal implementation
231func (m *mockAgent) Init(loop.AgentInit) error { return nil }
232func (m *mockAgent) Ready() <-chan struct{} { ch := make(chan struct{}); close(ch); return ch }
233func (m *mockAgent) URL() string { return "http://localhost:8080" }
234func (m *mockAgent) UserMessage(ctx context.Context, msg string) {}
235func (m *mockAgent) Loop(ctx context.Context) {}
236func (m *mockAgent) CancelTurn(cause error) {}
237func (m *mockAgent) CancelToolUse(id string, cause error) error { return nil }
238func (m *mockAgent) TotalUsage() conversation.CumulativeUsage { return conversation.CumulativeUsage{} }
239func (m *mockAgent) OriginalBudget() conversation.Budget { return conversation.Budget{} }
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000240func (m *mockAgent) WorkingDir() string { return m.workingDir }
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000241func (m *mockAgent) RepoRoot() string { return m.workingDir }
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000242func (m *mockAgent) Diff(commit *string) (string, error) { return "", nil }
243func (m *mockAgent) OS() string { return "linux" }
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700244func (m *mockAgent) SessionID() string { return m.sessionID }
philip.zeyliger8773e682025-06-11 21:36:21 -0700245func (m *mockAgent) SSHConnectionString() string { return "sketch-" + m.sessionID }
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000246func (m *mockAgent) BranchPrefix() string { return m.branchPrefix }
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700247func (m *mockAgent) CurrentTodoContent() string { return "" } // Mock returns empty for simplicity
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000248func (m *mockAgent) OutstandingLLMCallCount() int { return 0 }
249func (m *mockAgent) OutstandingToolCalls() []string { return nil }
250func (m *mockAgent) OutsideOS() string { return "linux" }
251func (m *mockAgent) OutsideHostname() string { return "test-host" }
252func (m *mockAgent) OutsideWorkingDir() string { return "/app" }
253func (m *mockAgent) GitOrigin() string { return "" }
bankseancad67b02025-06-27 21:57:05 +0000254func (m *mockAgent) GitUsername() string { return m.gitUsername }
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700255func (m *mockAgent) PassthroughUpstream() bool { return false }
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000256func (m *mockAgent) OpenBrowser(url string) {}
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700257func (m *mockAgent) CompactConversation(ctx context.Context) error {
258 // Mock implementation - just return nil
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000259 return nil
260}
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700261func (m *mockAgent) IsInContainer() bool { return false }
262func (m *mockAgent) FirstMessageIndex() int { return 0 }
263func (m *mockAgent) DetectGitChanges(ctx context.Context) error { return nil }
Philip Zeyligerb5739402025-06-02 07:04:34 -0700264
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700265func (m *mockAgent) Slug() string {
266 m.mu.RLock()
267 defer m.mu.RUnlock()
268 return m.slug
269}
270
271func (m *mockAgent) IncrementRetryNumber() {
272 m.mu.Lock()
273 defer m.mu.Unlock()
274 m.retryNumber++
275}
276
Philip Zeyligerda623b52025-07-04 01:12:38 +0000277func (m *mockAgent) SkabandAddr() string { return m.skabandAddr }
278func (m *mockAgent) LinkToGitHub() bool { return false }
279func (m *mockAgent) DiffStats() (int, int) { return 0, 0 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000280func (m *mockAgent) GetPorts() []portlist.Port {
281 // Mock returns a few test ports
282 return []portlist.Port{
283 {Proto: "tcp", Port: 22, Process: "sshd", Pid: 1234},
284 {Proto: "tcp", Port: 80, Process: "nginx", Pid: 5678},
285 {Proto: "tcp", Port: 8080, Process: "test-server", Pid: 9012},
286 }
287}
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700288
289// TestSSEStream tests the SSE stream endpoint
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000290func TestSSEStream(t *testing.T) {
291 // Create a mock agent with initial messages
292 mockAgent := &mockAgent{
Philip Zeyligereab12de2025-05-14 02:35:53 +0000293 messages: []loop.AgentMessage{},
294 messageCount: 0,
295 currentState: "Ready",
296 subscribers: []chan *loop.AgentMessage{},
297 stateTransitionListeners: []chan loop.StateTransition{},
298 initialCommit: "abcd1234",
Philip Zeyligereab12de2025-05-14 02:35:53 +0000299 branchName: "sketch/test-branch",
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000300 branchPrefix: "sketch/",
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700301 slug: "test-slug",
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +0000302 model: "fake-model",
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000303 }
304
305 // Add the initial messages before creating the server
306 // to ensure they're available in the Messages slice
307 msg1 := loop.AgentMessage{
308 Type: loop.UserMessageType,
309 Content: "Hello, this is a test message",
310 Timestamp: time.Now(),
311 }
312 mockAgent.messages = append(mockAgent.messages, msg1)
313 msg1.Idx = mockAgent.messageCount
314 mockAgent.messageCount++
315
316 msg2 := loop.AgentMessage{
317 Type: loop.AgentMessageType,
318 Content: "This is a response message",
319 Timestamp: time.Now(),
320 EndOfTurn: true,
321 }
322 mockAgent.messages = append(mockAgent.messages, msg2)
323 msg2.Idx = mockAgent.messageCount
324 mockAgent.messageCount++
325
326 // Create a server with the mock agent
327 srv, err := server.New(mockAgent, nil)
328 if err != nil {
329 t.Fatalf("Failed to create server: %v", err)
330 }
331
332 // Create a test server
333 ts := httptest.NewServer(srv)
334 defer ts.Close()
335
336 // Create a context with cancellation for the client request
337 ctx, cancel := context.WithCancel(context.Background())
338
339 // Create a request to the /stream endpoint
340 req, err := http.NewRequestWithContext(ctx, "GET", ts.URL+"/stream?from=0", nil)
341 if err != nil {
342 t.Fatalf("Failed to create request: %v", err)
343 }
344
345 // Execute the request
346 res, err := http.DefaultClient.Do(req)
347 if err != nil {
348 t.Fatalf("Failed to execute request: %v", err)
349 }
350 defer res.Body.Close()
351
352 // Check response status
353 if res.StatusCode != http.StatusOK {
354 t.Fatalf("Expected status OK, got %v", res.Status)
355 }
356
357 // Check content type
358 if contentType := res.Header.Get("Content-Type"); contentType != "text/event-stream" {
359 t.Fatalf("Expected Content-Type text/event-stream, got %s", contentType)
360 }
361
362 // Read response events using a scanner
363 scanner := bufio.NewScanner(res.Body)
364
365 // Track events received
366 eventsReceived := map[string]int{
367 "state": 0,
368 "message": 0,
369 "heartbeat": 0,
370 }
371
372 // Read for a short time to capture initial state and messages
373 dataLines := []string{}
374 eventType := ""
375
376 go func() {
377 // After reading for a while, add a new message to test real-time updates
378 time.Sleep(500 * time.Millisecond)
379
380 mockAgent.AddMessage(loop.AgentMessage{
381 Type: loop.ToolUseMessageType,
382 Content: "This is a new real-time message",
383 Timestamp: time.Now(),
384 ToolName: "test_tool",
385 })
386
Philip Zeyligereab12de2025-05-14 02:35:53 +0000387 // Trigger a state transition to test state updates
388 time.Sleep(200 * time.Millisecond)
389 mockAgent.TriggerStateTransition(loop.StateReady, loop.StateSendingToLLM, loop.TransitionEvent{
390 Description: "Agent started thinking",
391 Data: "start_thinking",
392 })
393
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000394 // Let it process for longer
395 time.Sleep(1000 * time.Millisecond)
396 cancel() // Cancel to end the test
397 }()
398
399 // Read events
400 for scanner.Scan() {
401 line := scanner.Text()
402
403 if strings.HasPrefix(line, "event: ") {
404 eventType = strings.TrimPrefix(line, "event: ")
405 eventsReceived[eventType]++
406 } else if strings.HasPrefix(line, "data: ") {
407 dataLines = append(dataLines, line)
408 } else if line == "" && eventType != "" {
409 // End of event
410 eventType = ""
411 }
412
413 // Break if context is done
414 if ctx.Err() != nil {
415 break
416 }
417 }
418
419 if err := scanner.Err(); err != nil && ctx.Err() == nil {
420 t.Fatalf("Scanner error: %v", err)
421 }
422
423 // Simplified validation - just make sure we received something
424 t.Logf("Events received: %v", eventsReceived)
425 t.Logf("Data lines received: %d", len(dataLines))
426
427 // Basic validation that we received at least some events
428 if eventsReceived["state"] == 0 && eventsReceived["message"] == 0 {
429 t.Errorf("Did not receive any events")
430 }
431}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000432
433func TestGitRawDiffHandler(t *testing.T) {
434 // Create a mock agent
435 mockAgent := &mockAgent{
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000436 workingDir: t.TempDir(), // Use a temp directory
437 branchPrefix: "sketch/",
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +0000438 model: "fake-model",
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000439 }
440
441 // Create the server with the mock agent
442 server, err := server.New(mockAgent, nil)
443 if err != nil {
444 t.Fatalf("Failed to create server: %v", err)
445 }
446
447 // Create a test HTTP server
448 testServer := httptest.NewServer(server)
449 defer testServer.Close()
450
451 // Test missing parameters
452 resp, err := http.Get(testServer.URL + "/git/rawdiff")
453 if err != nil {
454 t.Fatalf("Failed to make HTTP request: %v", err)
455 }
456 if resp.StatusCode != http.StatusBadRequest {
457 t.Errorf("Expected status bad request, got: %d", resp.StatusCode)
458 }
459
460 // Test with commit parameter (this will fail due to no git repo, but we're testing the API, not git)
461 resp, err = http.Get(testServer.URL + "/git/rawdiff?commit=HEAD")
462 if err != nil {
463 t.Fatalf("Failed to make HTTP request: %v", err)
464 }
465 // We expect an error since there's no git repository, but the request should be processed
466 if resp.StatusCode != http.StatusInternalServerError {
467 t.Errorf("Expected status 500, got: %d", resp.StatusCode)
468 }
469
470 // Test with from/to parameters
471 resp, err = http.Get(testServer.URL + "/git/rawdiff?from=HEAD~1&to=HEAD")
472 if err != nil {
473 t.Fatalf("Failed to make HTTP request: %v", err)
474 }
475 // We expect an error since there's no git repository, but the request should be processed
476 if resp.StatusCode != http.StatusInternalServerError {
477 t.Errorf("Expected status 500, got: %d", resp.StatusCode)
478 }
479}
480
481func TestGitShowHandler(t *testing.T) {
482 // Create a mock agent
483 mockAgent := &mockAgent{
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000484 workingDir: t.TempDir(), // Use a temp directory
485 branchPrefix: "sketch/",
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +0000486 model: "fake-model",
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000487 }
488
489 // Create the server with the mock agent
490 server, err := server.New(mockAgent, nil)
491 if err != nil {
492 t.Fatalf("Failed to create server: %v", err)
493 }
494
495 // Create a test HTTP server
496 testServer := httptest.NewServer(server)
497 defer testServer.Close()
498
499 // Test missing parameter
500 resp, err := http.Get(testServer.URL + "/git/show")
501 if err != nil {
502 t.Fatalf("Failed to make HTTP request: %v", err)
503 }
504 if resp.StatusCode != http.StatusBadRequest {
505 t.Errorf("Expected status bad request, got: %d", resp.StatusCode)
506 }
507
508 // Test with hash parameter (this will fail due to no git repo, but we're testing the API, not git)
509 resp, err = http.Get(testServer.URL + "/git/show?hash=HEAD")
510 if err != nil {
511 t.Fatalf("Failed to make HTTP request: %v", err)
512 }
513 // We expect an error since there's no git repository, but the request should be processed
514 if resp.StatusCode != http.StatusInternalServerError {
515 t.Errorf("Expected status 500, got: %d", resp.StatusCode)
516 }
517}
518
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700519func TestCompactHandler(t *testing.T) {
520 // Test that mock CompactConversation works
521 mockAgent := &mockAgent{
522 messages: []loop.AgentMessage{},
523 messageCount: 0,
524 sessionID: "test-session",
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000525 branchPrefix: "sketch/",
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +0000526 model: "fake-model",
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700527 }
528
529 ctx := context.Background()
530 err := mockAgent.CompactConversation(ctx)
531 if err != nil {
532 t.Errorf("Mock CompactConversation failed: %v", err)
533 }
534
535 // No HTTP endpoint to test anymore - compaction is done via /compact message
536 t.Log("Mock CompactConversation works correctly")
537}
Sean McCullough138ec242025-06-02 22:42:06 +0000538
Philip Zeyligera9710d72025-07-02 02:50:14 +0000539func TestParsePortProxyHost(t *testing.T) {
540 tests := []struct {
541 name string
542 host string
543 wantPort string
544 }{
545 {
546 name: "valid port proxy host",
547 host: "p8000.localhost",
548 wantPort: "8000",
549 },
550 {
551 name: "valid port proxy host with port suffix",
552 host: "p8000.localhost:8080",
553 wantPort: "8000",
554 },
555 {
556 name: "different port",
557 host: "p3000.localhost",
558 wantPort: "3000",
559 },
560 {
561 name: "regular localhost",
562 host: "localhost",
563 wantPort: "",
564 },
565 {
566 name: "different domain",
567 host: "p8000.example.com",
568 wantPort: "",
569 },
570 {
571 name: "missing p prefix",
572 host: "8000.localhost",
573 wantPort: "",
574 },
575 {
576 name: "invalid port",
577 host: "pabc.localhost",
578 wantPort: "",
579 },
580 {
581 name: "just p prefix",
582 host: "p.localhost",
583 wantPort: "",
584 },
585 {
586 name: "port too high",
587 host: "p99999.localhost",
588 wantPort: "",
589 },
590 {
591 name: "port zero",
592 host: "p0.localhost",
593 wantPort: "",
594 },
595 {
596 name: "negative port",
597 host: "p-1.localhost",
598 wantPort: "",
599 },
600 }
601
602 // Create a test server to access the method
603 s, err := server.New(nil, nil)
604 if err != nil {
605 t.Fatalf("Failed to create server: %v", err)
606 }
607
608 for _, tt := range tests {
609 t.Run(tt.name, func(t *testing.T) {
610 gotPort := s.ParsePortProxyHost(tt.host)
611 if gotPort != tt.wantPort {
612 t.Errorf("parsePortProxyHost(%q) = %q, want %q", tt.host, gotPort, tt.wantPort)
613 }
614 })
615 }
616}
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000617
618// TestStateEndpointIncludesPorts tests that the /state endpoint includes port information
619func TestStateEndpointIncludesPorts(t *testing.T) {
620 mockAgent := &mockAgent{
621 messages: []loop.AgentMessage{},
622 messageCount: 0,
623 currentState: "initial",
624 subscribers: []chan *loop.AgentMessage{},
625 gitUsername: "test-user",
626 initialCommit: "abc123",
627 branchName: "test-branch",
628 branchPrefix: "test-",
629 workingDir: "/tmp/test",
630 sessionID: "test-session",
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +0000631 model: "fake-model",
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000632 slug: "test-slug",
633 skabandAddr: "http://localhost:8080",
634 }
635
636 // Create a test server
637 server, err := server.New(mockAgent, nil)
638 if err != nil {
639 t.Fatal(err)
640 }
641
642 // Create a test request to the /state endpoint
643 req, err := http.NewRequest("GET", "/state", nil)
644 if err != nil {
645 t.Fatal(err)
646 }
647
648 // Create a response recorder
649 rr := httptest.NewRecorder()
650
651 // Execute the request
652 server.ServeHTTP(rr, req)
653
654 // Check the response
655 if status := rr.Code; status != http.StatusOK {
656 t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK)
657 }
658
659 // Check that the response contains port information
660 responseBody := rr.Body.String()
661 t.Logf("Response body: %s", responseBody)
662
663 // Verify the response contains the expected ports
664 if !strings.Contains(responseBody, `"open_ports"`) {
665 t.Error("Response should contain 'open_ports' field")
666 }
667
668 if !strings.Contains(responseBody, `"port": 22`) {
669 t.Error("Response should contain port 22 from mock")
670 }
671
672 if !strings.Contains(responseBody, `"port": 80`) {
673 t.Error("Response should contain port 80 from mock")
674 }
675
676 if !strings.Contains(responseBody, `"port": 8080`) {
677 t.Error("Response should contain port 8080 from mock")
678 }
679
680 if !strings.Contains(responseBody, `"process": "sshd"`) {
681 t.Error("Response should contain process name 'sshd'")
682 }
683
684 if !strings.Contains(responseBody, `"process": "nginx"`) {
685 t.Error("Response should contain process name 'nginx'")
686 }
687
688 if !strings.Contains(responseBody, `"proto": "tcp"`) {
689 t.Error("Response should contain protocol 'tcp'")
690 }
691
692 t.Log("State endpoint includes port information correctly")
693}
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700694
695// TestGitPushHandler tests the git push endpoint
696func TestGitPushHandler(t *testing.T) {
697 mockAgent := &mockAgent{
698 workingDir: t.TempDir(),
699 branchPrefix: "sketch/",
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +0000700 model: "fake-model",
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700701 }
702
703 // Create the server with the mock agent
704 server, err := server.New(mockAgent, nil)
705 if err != nil {
706 t.Fatalf("Failed to create server: %v", err)
707 }
708
709 // Create a test HTTP server
710 testServer := httptest.NewServer(server)
711 defer testServer.Close()
712
713 // Test missing required parameters
714 tests := []struct {
715 name string
716 requestBody string
717 expectedStatus int
718 expectedError string
719 }{
720 {
721 name: "missing all parameters",
722 requestBody: `{}`,
723 expectedStatus: http.StatusBadRequest,
724 expectedError: "Missing required parameters: remote, branch, and commit",
725 },
726 {
727 name: "missing commit parameter",
728 requestBody: `{"remote": "origin", "branch": "main"}`,
729 expectedStatus: http.StatusBadRequest,
730 expectedError: "Missing required parameters: remote, branch, and commit",
731 },
732 {
733 name: "missing remote parameter",
734 requestBody: `{"branch": "main", "commit": "abc123"}`,
735 expectedStatus: http.StatusBadRequest,
736 expectedError: "Missing required parameters: remote, branch, and commit",
737 },
738 {
739 name: "missing branch parameter",
740 requestBody: `{"remote": "origin", "commit": "abc123"}`,
741 expectedStatus: http.StatusBadRequest,
742 expectedError: "Missing required parameters: remote, branch, and commit",
743 },
744 {
745 name: "all parameters present",
746 requestBody: `{"remote": "origin", "branch": "main", "commit": "abc123", "dry_run": true}`,
747 expectedStatus: http.StatusOK, // Parameters are valid, response will be JSON
748 expectedError: "", // No parameter validation error
749 },
750 }
751
752 for _, tt := range tests {
753 t.Run(tt.name, func(t *testing.T) {
754 resp, err := http.Post(
755 testServer.URL+"/git/push",
756 "application/json",
757 strings.NewReader(tt.requestBody),
758 )
759 if err != nil {
760 t.Fatalf("Failed to make HTTP request: %v", err)
761 }
762 defer resp.Body.Close()
763
764 if resp.StatusCode != tt.expectedStatus {
765 t.Errorf("Expected status %d, got: %d", tt.expectedStatus, resp.StatusCode)
766 }
767
768 if tt.expectedError != "" {
769 body, err := io.ReadAll(resp.Body)
770 if err != nil {
771 t.Fatalf("Failed to read response body: %v", err)
772 }
773 if !strings.Contains(string(body), tt.expectedError) {
774 t.Errorf("Expected error message '%s', got: %s", tt.expectedError, string(body))
775 }
776 }
777 })
778 }
779}
780
781// TestGitPushInfoHandler tests the git push info endpoint
782func TestGitPushInfoHandler(t *testing.T) {
783 mockAgent := &mockAgent{
784 workingDir: t.TempDir(),
785 branchPrefix: "sketch/",
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +0000786 model: "fake-model",
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700787 }
788
789 // Create the server with the mock agent
790 server, err := server.New(mockAgent, nil)
791 if err != nil {
792 t.Fatalf("Failed to create server: %v", err)
793 }
794
795 // Create a test HTTP server
796 testServer := httptest.NewServer(server)
797 defer testServer.Close()
798
799 // Test GET request
800 resp, err := http.Get(testServer.URL + "/git/pushinfo")
801 if err != nil {
802 t.Fatalf("Failed to make HTTP request: %v", err)
803 }
804 defer resp.Body.Close()
805
806 // We expect this to fail with 500 since there's no git repository
807 // but the endpoint should be accessible
808 if resp.StatusCode != http.StatusInternalServerError {
809 t.Errorf("Expected status 500, got: %d", resp.StatusCode)
810 }
811
812 // Test that POST is not allowed
813 resp, err = http.Post(testServer.URL+"/git/pushinfo", "application/json", strings.NewReader(`{}`))
814 if err != nil {
815 t.Fatalf("Failed to make HTTP request: %v", err)
816 }
817 defer resp.Body.Close()
818
819 if resp.StatusCode != http.StatusMethodNotAllowed {
820 t.Errorf("Expected status 405, got: %d", resp.StatusCode)
821 }
822}