summaryrefslogtreecommitdiff
path: root/internal/api/logs_test.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-06 23:55:07 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-06 23:55:07 +0000
commitfd42a54d96fcd3342941caaeb61a4b0d5d3f1b4f (patch)
tree0b9ef3b7f0ac3981aa310435d014c9f5e21089d4 /internal/api/logs_test.go
parent7d4890cde802974b94db24071f63e7733c3670fd (diff)
recover: restore untracked work from recovery branch (no Gemini changes)
Recovered files with no Claude→Agent contamination: - docs/adr/002-task-state-machine.md - internal/api/logs.go/logs_test.go: task-level log streaming endpoint - internal/api/validate.go/validate_test.go: POST /api/tasks/validate - internal/api/server_test.go, storage/db_test.go: expanded test coverage - scripts/reset-failed-tasks, reset-running-tasks - web/app.js, index.html, style.css: frontend improvements - web/test/: active-tasks-tab, delete-button, filter-tabs, sort-tasks tests Manually applied from server.go diff (skipping Claude→Agent rename): - taskLogStore field + validateCmdPath field - DELETE /api/tasks/{id} route + handleDeleteTask - GET /api/tasks/{id}/logs/stream route - POST /api/tasks/{id}/resume route + handleResumeTimedOutTask - handleCancelTask: allow cancelling PENDING/QUEUED tasks directly 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.go175
1 files changed, 175 insertions, 0 deletions
diff --git a/internal/api/logs_test.go b/internal/api/logs_test.go
index 4a0c9fd..52fa168 100644
--- a/internal/api/logs_test.go
+++ b/internal/api/logs_test.go
@@ -14,6 +14,26 @@ import (
"github.com/thepeterstone/claudomator/internal/storage"
)
+// mockTaskLogStore implements taskLogStore for testing handleStreamTaskLogs.
+type mockTaskLogStore struct {
+ getExecution func(id string) (*storage.Execution, error)
+ getLatestExecution func(taskID string) (*storage.Execution, error)
+}
+
+func (m *mockTaskLogStore) GetExecution(id string) (*storage.Execution, error) {
+ return m.getExecution(id)
+}
+
+func (m *mockTaskLogStore) GetLatestExecution(taskID string) (*storage.Execution, error) {
+ return m.getLatestExecution(taskID)
+}
+
+func taskLogsMux(srv *Server) *http.ServeMux {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/tasks/{id}/logs/stream", srv.handleStreamTaskLogs)
+ return mux
+}
+
// mockLogStore implements logStore for testing.
type mockLogStore struct {
fn func(id string) (*storage.Execution, error)
@@ -170,3 +190,158 @@ func TestHandleStreamLogs_RunningState_LiveTail(t *testing.T) {
t.Errorf("body does not end with done event; got:\n%s", body)
}
}
+
+// --- Task-level SSE log streaming tests (handleStreamTaskLogs) ---
+
+// TestHandleStreamTaskLogs_TaskNotFound verifies that a task with no executions yields 404.
+func TestHandleStreamTaskLogs_TaskNotFound(t *testing.T) {
+ srv := &Server{
+ taskLogStore: &mockTaskLogStore{
+ getLatestExecution: func(taskID string) (*storage.Execution, error) {
+ return nil, errors.New("not found")
+ },
+ },
+ }
+
+ req := httptest.NewRequest("GET", "/api/tasks/nonexistent/logs/stream", nil)
+ w := httptest.NewRecorder()
+ taskLogsMux(srv).ServeHTTP(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Errorf("status: want 404, got %d; body: %s", w.Code, w.Body.String())
+ }
+}
+
+// TestHandleStreamTaskLogs_NoStdoutPath_EmitsDone verifies that a completed execution with no
+// stdout log path emits only the done sentinel event.
+func TestHandleStreamTaskLogs_NoStdoutPath_EmitsDone(t *testing.T) {
+ exec := &storage.Execution{
+ ID: "exec-task-empty",
+ TaskID: "task-no-log",
+ StartTime: time.Now(),
+ Status: "COMPLETED",
+ // StdoutPath intentionally empty
+ }
+ srv := &Server{
+ taskLogStore: &mockTaskLogStore{
+ getLatestExecution: func(taskID string) (*storage.Execution, error) {
+ return exec, nil
+ },
+ getExecution: func(id string) (*storage.Execution, error) {
+ return exec, nil
+ },
+ },
+ }
+
+ req := httptest.NewRequest("GET", "/api/tasks/task-no-log/logs/stream", nil)
+ w := httptest.NewRecorder()
+ taskLogsMux(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 body != "event: done\ndata: {}\n\n" {
+ t.Errorf("want only done event, got:\n%s", body)
+ }
+}
+
+// TestHandleStreamTaskLogs_TerminalExecution_EmitsEventsAndDone verifies that a COMPLETED
+// execution streams SSE events and ends with a done event.
+func TestHandleStreamTaskLogs_TerminalExecution_EmitsEventsAndDone(t *testing.T) {
+ dir := t.TempDir()
+ logPath := filepath.Join(dir, "stdout.log")
+ lines := strings.Join([]string{
+ `{"type":"assistant","message":{"content":[{"type":"text","text":"Task output here"}]}}`,
+ `{"type":"result","cost_usd":0.007}`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(logPath, []byte(lines), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ exec := &storage.Execution{
+ ID: "exec-task-done",
+ TaskID: "task-done",
+ StartTime: time.Now(),
+ Status: "COMPLETED",
+ StdoutPath: logPath,
+ }
+ srv := &Server{
+ taskLogStore: &mockTaskLogStore{
+ getLatestExecution: func(taskID string) (*storage.Execution, error) {
+ return exec, nil
+ },
+ getExecution: func(id string) (*storage.Execution, error) {
+ return exec, nil
+ },
+ },
+ }
+
+ req := httptest.NewRequest("GET", "/api/tasks/task-done/logs/stream", nil)
+ w := httptest.NewRecorder()
+ taskLogsMux(srv).ServeHTTP(w, req)
+
+ body := w.Body.String()
+ if !strings.Contains(body, `"Task output here"`) {
+ t.Errorf("expected text event content 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)
+ }
+}
+
+// TestHandleStreamTaskLogs_RunningExecution_LiveTails verifies that a RUNNING execution is
+// live-tailed and a done event is emitted once it transitions to a terminal state.
+func TestHandleStreamTaskLogs_RunningExecution_LiveTails(t *testing.T) {
+ dir := t.TempDir()
+ logPath := filepath.Join(dir, "stdout.log")
+ logLines := strings.Join([]string{
+ `{"type":"assistant","message":{"content":[{"type":"text","text":"Still running..."}]}}`,
+ `{"type":"result","cost_usd":0.003}`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(logPath, []byte(logLines), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ runningExec := &storage.Execution{
+ ID: "exec-task-running",
+ TaskID: "task-running",
+ StartTime: time.Now(),
+ Status: "RUNNING",
+ StdoutPath: logPath,
+ }
+
+ // getLatestExecution is called once (initial lookup); getExecution polls for state change.
+ var pollCount atomic.Int32
+ srv := &Server{
+ taskLogStore: &mockTaskLogStore{
+ getLatestExecution: func(taskID string) (*storage.Execution, error) {
+ return runningExec, nil
+ },
+ getExecution: func(id string) (*storage.Execution, error) {
+ n := pollCount.Add(1)
+ if n <= 1 {
+ return runningExec, nil
+ }
+ completed := *runningExec
+ completed.Status = "COMPLETED"
+ return &completed, nil
+ },
+ },
+ }
+
+ req := httptest.NewRequest("GET", "/api/tasks/task-running/logs/stream", nil)
+ w := httptest.NewRecorder()
+ taskLogsMux(srv).ServeHTTP(w, req)
+
+ body := w.Body.String()
+ if !strings.Contains(body, `"Still running..."`) {
+ t.Errorf("expected live-tail text 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)
+ }
+}