package api import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) // createFakeClaude writes a shell script to a temp dir that prints output and exits with the // given code. Returns the script path. Used to mock the claude binary in elaborate tests. func createFakeClaude(t *testing.T, output string, exitCode int) string { t.Helper() dir := t.TempDir() outputFile := filepath.Join(dir, "output.json") if err := os.WriteFile(outputFile, []byte(output), 0600); err != nil { t.Fatal(err) } script := filepath.Join(dir, "claude") content := fmt.Sprintf("#!/bin/sh\ncat %q\nexit %d\n", outputFile, exitCode) if err := os.WriteFile(script, []byte(content), 0755); err != nil { t.Fatal(err) } return script } func TestElaboratePrompt_ContainsWorkDir(t *testing.T) { prompt := buildElaboratePrompt("/some/custom/path") if !strings.Contains(prompt, "/some/custom/path") { t.Error("prompt should contain the provided workDir") } if strings.Contains(prompt, "/root/workspace/claudomator") { t.Error("prompt should not hardcode /root/workspace/claudomator") } } func TestElaboratePrompt_EmptyWorkDir(t *testing.T) { prompt := buildElaboratePrompt("") if strings.Contains(prompt, "/root") { t.Error("prompt should not reference /root when workDir is empty") } } func TestElaborateTask_Success(t *testing.T) { srv, _ := testServer(t) // Build fake Claude output: {"result": ""} task := elaboratedTask{ Name: "Run Go tests with race detector", Description: "Runs the Go test suite with -race flag and checks coverage.", Agent: elaboratedAgent{ Type: "claude", Model: "sonnet", Instructions: "Run go test -race ./... and report results.", ProjectDir: "", MaxBudgetUSD: 0.5, AllowedTools: []string{"Bash"}, }, Timeout: "15m", Priority: "normal", Tags: []string{"testing", "ci"}, } taskJSON, err := json.Marshal(task) if err != nil { t.Fatal(err) } wrapper := map[string]string{"result": string(taskJSON)} wrapperJSON, err := json.Marshal(wrapper) if err != nil { t.Fatal(err) } srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0) body := `{"prompt":"run the Go test suite with race detector and fail if coverage < 80%"}` req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) } var result elaboratedTask if err := json.NewDecoder(w.Body).Decode(&result); err != nil { t.Fatalf("failed to decode response: %v", err) } if result.Name == "" { t.Error("expected non-empty name") } if result.Agent.Instructions == "" { t.Error("expected non-empty instructions") } } func TestElaborateTask_EmptyPrompt(t *testing.T) { srv, _ := testServer(t) body := `{"prompt":""}` req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("status: want 400, got %d; body: %s", w.Code, w.Body.String()) } var resp map[string]string json.NewDecoder(w.Body).Decode(&resp) if resp["error"] == "" { t.Error("expected error message in response") } } func TestElaborateTask_MarkdownFencedJSON(t *testing.T) { srv, _ := testServer(t) // Build a valid task JSON but wrap it in markdown fences as haiku sometimes does. task := elaboratedTask{ Name: "Test task", Description: "Does something.", Agent: elaboratedAgent{ Type: "claude", Model: "sonnet", Instructions: "Do the thing.", MaxBudgetUSD: 0.5, AllowedTools: []string{"Bash"}, }, Timeout: "15m", Priority: "normal", Tags: []string{"test"}, } taskJSON, _ := json.Marshal(task) fenced := "```json\n" + string(taskJSON) + "\n```" wrapper := map[string]string{"result": fenced} wrapperJSON, _ := json.Marshal(wrapper) srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0) body := `{"prompt":"do something"}` req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) } var result elaboratedTask if err := json.NewDecoder(w.Body).Decode(&result); err != nil { t.Fatalf("failed to decode response: %v", err) } if result.Name != task.Name { t.Errorf("name: want %q, got %q", task.Name, result.Name) } } func TestElaborateTask_InvalidJSONFromClaude(t *testing.T) { srv, _ := testServer(t) // Fake Claude returns something that is not valid JSON. srv.elaborateCmdPath = createFakeClaude(t, "not valid json at all", 0) body := `{"prompt":"do something"}` req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) if w.Code != http.StatusBadGateway { t.Fatalf("status: want 502, got %d; body: %s", w.Code, w.Body.String()) } var resp map[string]string json.NewDecoder(w.Body).Decode(&resp) if resp["error"] == "" { t.Error("expected error message in response") } } func createFakeClaudeCapturingArgs(t *testing.T, output string, exitCode int, argsFile string) string { t.Helper() dir := t.TempDir() outputFile := filepath.Join(dir, "output.json") if err := os.WriteFile(outputFile, []byte(output), 0600); err != nil { t.Fatal(err) } script := filepath.Join(dir, "claude") // Use printf to handle arguments safely content := fmt.Sprintf("#!/bin/sh\nprintf \"%%s\\n\" \"$@\" > %q\ncat %q\nexit %d\n", argsFile, outputFile, exitCode) if err := os.WriteFile(script, []byte(content), 0755); err != nil { t.Fatal(err) } return script } func TestElaborateTask_WithProjectContext(t *testing.T) { srv, _ := testServer(t) // Create a temporary workspace with CLAUDE.md and SESSION_STATE.md workDir := t.TempDir() claudeContent := "Claude context info" sessionContent := "Session state info" if err := os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(claudeContent), 0600); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(workDir, "SESSION_STATE.md"), []byte(sessionContent), 0600); err != nil { t.Fatal(err) } // Capture arguments passed to claude argsFile := filepath.Join(t.TempDir(), "args.txt") task := elaboratedTask{ Name: "Task with context", Agent: elaboratedAgent{ Instructions: "Instructions", }, } taskJSON, _ := json.Marshal(task) wrapper := map[string]string{"result": string(taskJSON)} wrapperJSON, _ := json.Marshal(wrapper) // Modified createFakeClaude to capture arguments srv.elaborateCmdPath = createFakeClaudeCapturingArgs(t, string(wrapperJSON), 0, argsFile) body := fmt.Sprintf(`{"prompt":"do something", "project_dir":"%s"}`, workDir) req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) } // Check if captured arguments contain the context capturedArgs, err := os.ReadFile(argsFile) if err != nil { t.Fatal(err) } argsStr := string(capturedArgs) if !strings.Contains(argsStr, claudeContent) { t.Errorf("expected arguments to contain CLAUDE.md content, got %s", argsStr) } if !strings.Contains(argsStr, sessionContent) { t.Errorf("expected arguments to contain SESSION_STATE.md content, got %s", argsStr) } }