diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-03-03 21:15:50 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-03 21:15:50 +0000 |
| commit | 74cc740398cf2d90804ab19db728c844c2e056b7 (patch) | |
| tree | e8532d1da9273e1613beb7b762b16134da0de286 /internal/api/logs_test.go | |
| parent | f527972f4d8311a09e639ede6c4da4ca669cfd5e (diff) | |
Add elaborate, logs-stream, templates, and subtask-list endpoints
- POST /api/tasks/elaborate: calls claude to draft a task config from
a natural-language prompt
- GET /api/executions/{id}/logs/stream: SSE tail of stdout.log
- CRUD /api/templates: create/list/get/update/delete reusable task configs
- GET /api/tasks/{id}/subtasks: list child tasks
- Server.NewServer accepts claudeBinPath for elaborate; injectable
elaborateCmdPath and logStore for test isolation
- Valid-transition guard added to POST /api/tasks/{id}/run
- CLI passes claude binary path through to the server
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/api/logs_test.go')
| -rw-r--r-- | internal/api/logs_test.go | 172 |
1 files changed, 172 insertions, 0 deletions
diff --git a/internal/api/logs_test.go b/internal/api/logs_test.go new file mode 100644 index 0000000..4a0c9fd --- /dev/null +++ b/internal/api/logs_test.go @@ -0,0 +1,172 @@ +package api + +import ( + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/thepeterstone/claudomator/internal/storage" +) + +// mockLogStore implements logStore for testing. +type mockLogStore struct { + fn func(id string) (*storage.Execution, error) +} + +func (m *mockLogStore) GetExecution(id string) (*storage.Execution, error) { + return m.fn(id) +} + +func logsMux(srv *Server) *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/executions/{id}/logs", srv.handleStreamLogs) + return mux +} + +// TestHandleStreamLogs_NotFound verifies that an unknown execution ID yields 404. +func TestHandleStreamLogs_NotFound(t *testing.T) { + srv := &Server{ + logStore: &mockLogStore{fn: func(id string) (*storage.Execution, error) { + return nil, errors.New("not found") + }}, + } + + req := httptest.NewRequest("GET", "/api/executions/nonexistent/logs", nil) + w := httptest.NewRecorder() + logsMux(srv).ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: want 404, got %d; body: %s", w.Code, w.Body.String()) + } +} + +// TestHandleStreamLogs_TerminalState_EmitsEventsAndDone verifies that a COMPLETED +// execution with a populated stdout.log streams SSE events and terminates with a done event. +func TestHandleStreamLogs_TerminalState_EmitsEventsAndDone(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "stdout.log") + lines := strings.Join([]string{ + `{"type":"assistant","message":{"content":[{"type":"text","text":"Hello world"}]}}`, + `{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}}]}}`, + `{"type":"result","cost_usd":0.0042}`, + }, "\n") + "\n" + if err := os.WriteFile(logPath, []byte(lines), 0600); err != nil { + t.Fatal(err) + } + + exec := &storage.Execution{ + ID: "exec-terminal-1", + TaskID: "task-terminal-1", + StartTime: time.Now(), + Status: "COMPLETED", + StdoutPath: logPath, + } + srv := &Server{ + logStore: &mockLogStore{fn: func(id string) (*storage.Execution, error) { + return exec, nil + }}, + } + + req := httptest.NewRequest("GET", "/api/executions/exec-terminal-1/logs", nil) + w := httptest.NewRecorder() + logsMux(srv).ServeHTTP(w, req) + + if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") { + t.Errorf("Content-Type: want text/event-stream, got %q", ct) + } + body := w.Body.String() + if !strings.Contains(body, "data: ") { + t.Error("expected at least one SSE 'data: ' event in body") + } + if !strings.Contains(body, "\n\n") { + t.Error("expected SSE double-newline event termination") + } + if !strings.HasSuffix(body, "event: done\ndata: {}\n\n") { + t.Errorf("body does not end with done event; got:\n%s", body) + } +} + +// TestHandleStreamLogs_EmptyLog verifies that a COMPLETED execution with no stdout path +// responds with only the done sentinel event. +func TestHandleStreamLogs_EmptyLog(t *testing.T) { + exec := &storage.Execution{ + ID: "exec-empty-1", + TaskID: "task-empty-1", + StartTime: time.Now(), + Status: "COMPLETED", + // StdoutPath intentionally empty + } + srv := &Server{ + logStore: &mockLogStore{fn: func(id string) (*storage.Execution, error) { + return exec, nil + }}, + } + + req := httptest.NewRequest("GET", "/api/executions/exec-empty-1/logs", nil) + w := httptest.NewRecorder() + logsMux(srv).ServeHTTP(w, req) + + body := w.Body.String() + if body != "event: done\ndata: {}\n\n" { + t.Errorf("want only done event, got:\n%s", body) + } +} + +// TestHandleStreamLogs_RunningState_LiveTail verifies that a RUNNING execution streams +// initial log content and emits a done event once it transitions to a terminal state. +func TestHandleStreamLogs_RunningState_LiveTail(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "stdout.log") + logLines := strings.Join([]string{ + `{"type":"assistant","message":{"content":[{"type":"text","text":"Working..."}]}}`, + `{"type":"result","cost_usd":0.001}`, + }, "\n") + "\n" + if err := os.WriteFile(logPath, []byte(logLines), 0600); err != nil { + t.Fatal(err) + } + + runningExec := &storage.Execution{ + ID: "exec-running-1", + TaskID: "task-running-1", + StartTime: time.Now(), + Status: "RUNNING", + StdoutPath: logPath, + } + + // callCount tracks how many times GetExecution has been called. + // Call 1: initial fetch in handleStreamLogs → RUNNING + // Call 2+: poll in tailRunningExecution → COMPLETED + var callCount atomic.Int32 + mock := &mockLogStore{fn: func(id string) (*storage.Execution, error) { + n := callCount.Add(1) + if n <= 1 { + return runningExec, nil + } + completed := *runningExec + completed.Status = "COMPLETED" + return &completed, nil + }} + + srv := &Server{logStore: mock} + + req := httptest.NewRequest("GET", "/api/executions/exec-running-1/logs", nil) + w := httptest.NewRecorder() + logsMux(srv).ServeHTTP(w, req) + + body := w.Body.String() + if !strings.Contains(body, `"Working..."`) { + t.Errorf("expected initial text event in body; got:\n%s", body) + } + if !strings.Contains(body, `"type":"cost"`) { + t.Errorf("expected cost event in body; got:\n%s", body) + } + if !strings.HasSuffix(body, "event: done\ndata: {}\n\n") { + t.Errorf("body does not end with done event; got:\n%s", body) + } +} |
