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