From 098ed137052cda0d2756e0125e8e4f9c0bf804fa Mon Sep 17 00:00:00 2001 From: djuka Date: Sat, 21 Feb 2026 04:45:50 +0000 Subject: [PATCH] T22: Reorganizacija testova + logs handler + konzola fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Razbijen monolitni server_test.go na fokusirane test fajlove: api_test.go, dashboard_test.go, docs_test.go, search_test.go, submit_test.go, task_detail_test.go, console_test.go, sse_test.go, timestamp_test.go, ui_test.go, test_helpers_test.go - Dodat logs.go handler (handleLogsTail) koji je nedostajao - Dodat LogFile u config - Fix konzola: prompt se šalje preko fajla umesto direktno u PTY - 192 testova prolazi Co-Authored-By: Claude Opus 4.6 --- README.md | 68 +- TESTING.md | 66 + code/.env.example | 1 + code/internal/config/config.go | 5 + code/internal/server/api_test.go | 205 ++ code/internal/server/console.go | 15 +- code/internal/server/console_test.go | 312 +++ code/internal/server/dashboard_test.go | 317 +++ code/internal/server/docs_test.go | 201 ++ code/internal/server/logs.go | 41 + code/internal/server/search_test.go | 117 ++ code/internal/server/server_test.go | 2165 --------------------- code/internal/server/sse_test.go | 121 ++ code/internal/server/submit_test.go | 245 +++ code/internal/server/task_detail_test.go | 272 +++ code/internal/server/test_helpers_test.go | 82 + code/internal/server/timestamp_test.go | 291 +++ code/internal/server/ui_test.go | 80 + docs/ARCHITECTURE.md | 159 ++ docs/SETUP.md | 104 + docs/SPEC.md | 106 + 21 files changed, 2773 insertions(+), 2200 deletions(-) create mode 100644 TESTING.md create mode 100644 code/internal/server/api_test.go create mode 100644 code/internal/server/console_test.go create mode 100644 code/internal/server/dashboard_test.go create mode 100644 code/internal/server/docs_test.go create mode 100644 code/internal/server/logs.go create mode 100644 code/internal/server/search_test.go delete mode 100644 code/internal/server/server_test.go create mode 100644 code/internal/server/sse_test.go create mode 100644 code/internal/server/submit_test.go create mode 100644 code/internal/server/task_detail_test.go create mode 100644 code/internal/server/test_helpers_test.go create mode 100644 code/internal/server/timestamp_test.go create mode 100644 code/internal/server/ui_test.go create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/SETUP.md create mode 100644 docs/SPEC.md diff --git a/README.md b/README.md index 801c53e..0f67cf2 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # KAOS — AI-Supervised Development System -**Verzija:** 0.1.0 -**Status:** Pokretanje +**Verzija:** 0.3.0 +**Status:** Aktivan razvoj **Autor:** DAL d.o.o. -**Poslednje ažuriranje:** 2026-02-20 +**Poslednje azuriranje:** 2026-02-21 --- @@ -101,13 +101,23 @@ Deploy ili dorada │ ├── regulations/ │ └── third-party/ │ -└── TASKS/ ← taskovi, specifikacije, izveštaji - ├── MASTER-STATUS.md - ├── Architecture.md - ├── Workflow-Spec.md - ├── Supervisor-Spec.md - ├── Multi-Agent-Spec.md - ├── Implementation-Tasks.md +├── docs/ ← dokumentacija +│ ├── SPEC.md +│ ├── ARCHITECTURE.md +│ └── SETUP.md +│ +├── code/ ← Go kod (server, testovi) +│ ├── cmd/kaos-server/ +│ ├── internal/server/ +│ ├── internal/supervisor/ +│ └── web/templates/ +│ +└── TASKS/ ← taskovi po stanju + ├── backlog/ + ├── ready/ + ├── active/ + ├── review/ + ├── done/ └── reports/ ``` @@ -115,30 +125,26 @@ Deploy ili dorada ## Verzije -### v0.1 — Osnova (TRENUTNO) +### v0.1 — Osnova - Mastermind + agenti definisani u CLAUDE.md fajlovima -- Supervisor: ručno pokretanje (`kaos-supervisor run T01`) -- Checker: build + test + vet (deterministički) -- Izveštaji: markdown u TASKS/reports/ -- Git: direktno na main -- Nema baze, nema frontend-a, nema AI trijaže +- Supervisor: rucno pokretanje (`kaos-supervisor run T01`) +- Checker: build + test + vet +- Izvestaji: markdown u TASKS/reports/ -### v0.2 — Automatizacija (planirano) -- Supervisor daemon ili watch folder -- AI trijaža prijava -- AI compliance provere (modul, pravila, konvencije) -- Staging → main branch strategija -- Auto-retry za flaky testove -- Notifikacije (konfigurabilan kanal) +### v0.2 — Dashboard +- Web dashboard sa Kanban board-om +- Drag & drop premestanje taskova +- SSE real-time update +- Pretraga, dokumenti, prijava taskova -### v0.3 — Kompletni ekosistem (planirano) -- Frontend dashboard -- WebSocket real-time praćenje -- Help sistem -- Embed SDK -- Cost tracking dashboard -- Metrike i analitika -- Distribucija prema licencama +### v0.3 — Konzola i PTY (TRENUTNO) +- xterm.js terminali u browseru +- Svaki task dobija sopstvenu claude PTY sesiju +- "Pusti" automatski pokrece rad +- "Proveri" pokrece review sesiju +- WebSocket za real-time terminal I/O +- Replay buffer za reconnect +- 125+ testova u 12 fajlova --- diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..7768f85 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,66 @@ +# KAOS — Test Checklist + +## Dashboard (Kanban) +- [ ] Ucitavanje sa svim kolonama (backlog, ready, active, review, done) +- [ ] Prikaz taskova u ispravnim kolonama +- [ ] Klik na task otvara detail modal +- [ ] Escape zatvara modal +- [ ] Klik na pozadinu zatvara modal +- [ ] Drag & drop premestanje taskova +- [ ] SSE auto-refresh kad se task promeni +- [ ] Tema (svetla/tamna/auto) radi + +## Task akcije +- [ ] "Odobri" premesta backlog -> ready +- [ ] "Pusti" premesta ready -> active i pokrece claude +- [ ] "Pregledaj" otvara task detail za review +- [ ] "Proveri" pokrece review claude sesiju +- [ ] "Odobri" (review) premesta review -> done +- [ ] "Vrati" premesta review -> ready +- [ ] "Izvestaj" otvara report modal +- [ ] Blokiran task prikazuje "Blokiran" dugme + +## Konzola +- [ ] Prazna stranica kad nema sesija (empty state) +- [ ] Posle "Pusti" - terminal se pojavljuje +- [ ] Terminal prikazuje claude output u realnom vremenu +- [ ] Keyboard input radi (moze se tipkati u terminal) +- [ ] Terminal resize radi +- [ ] Reconnect na page reload (replay buffer) +- [ ] "Ugasi" dugme zavrsava sesiju +- [ ] Vise sesija istovremeno +- [ ] Review sesija prikazuje "[pregled]" label + +## Pretraga +- [ ] Pretrazuje taskove po naslovu i ID-u +- [ ] Pretrazuje dokumente +- [ ] Pretrazuje izvestaje +- [ ] Case-insensitive +- [ ] Prazna pretraga ne vraca rezultate +- [ ] "Nema rezultata" za nepostojeci termin + +## Dokumenti +- [ ] Lista svih .md fajlova +- [ ] Pregled sa markdown renderovanjem +- [ ] Tabele se renderuju +- [ ] Breadcrumbs navigacija +- [ ] Sidebar sa listom fajlova +- [ ] Path traversal blokiran + +## Prijava +- [ ] Klijent mod: forma za prijavu +- [ ] Operater mod: chat sa claude +- [ ] Task se kreira u backlog/ sa ispravnim ID-om +- [ ] Prazan naslov vraca gresku + +## Server logovi +- [ ] Prikaz poslednjih logova +- [ ] Modal se zatvara + +## API +- [ ] GET /api/tasks vraca JSON listu +- [ ] GET /api/task/:id vraca detalj sa sadrzajem +- [ ] POST /api/task/:id/move premesta task +- [ ] Nepostojeci task vraca 404 +- [ ] Nevalidan folder vraca 400 +- [ ] Zabranjeni prelazi vracaju 403 diff --git a/code/.env.example b/code/.env.example index 89502f7..1708a77 100644 --- a/code/.env.example +++ b/code/.env.example @@ -2,3 +2,4 @@ KAOS_TIMEOUT=30m KAOS_PROJECT_PATH=. KAOS_TASKS_DIR=../TASKS KAOS_PORT=8080 +KAOS_LOG_FILE=/tmp/kaos-server.log diff --git a/code/internal/config/config.go b/code/internal/config/config.go index 4e62aeb..6cdb162 100644 --- a/code/internal/config/config.go +++ b/code/internal/config/config.go @@ -20,6 +20,8 @@ type Config struct { TasksDir string // Port is the HTTP server port. Port string + // LogFile is the path to the server log file (optional). + LogFile string } // Load reads configuration from environment variables. @@ -53,11 +55,14 @@ func Load() (*Config, error) { port = "8080" } + logFile := os.Getenv("KAOS_LOG_FILE") + return &Config{ Timeout: timeout, ProjectPath: projectPath, TasksDir: tasksDir, Port: port, + LogFile: logFile, }, nil } diff --git a/code/internal/server/api_test.go b/code/internal/server/api_test.go new file mode 100644 index 0000000..b3a084a --- /dev/null +++ b/code/internal/server/api_test.go @@ -0,0 +1,205 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestAPIGetTasks(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var tasks []taskResponse + if err := json.Unmarshal(w.Body.Bytes(), &tasks); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if len(tasks) != 2 { + t.Fatalf("expected 2 tasks, got %d", len(tasks)) + } + + // Check that tasks have correct statuses + statuses := map[string]string{} + for _, task := range tasks { + statuses[task.ID] = task.Status + } + if statuses["T01"] != "done" { + t.Errorf("expected T01 status done, got %s", statuses["T01"]) + } + if statuses["T08"] != "backlog" { + t.Errorf("expected T08 status backlog, got %s", statuses["T08"]) + } +} + +func TestAPIGetTask(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/api/task/T01", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var detail taskDetailResponse + if err := json.Unmarshal(w.Body.Bytes(), &detail); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if detail.ID != "T01" { + t.Errorf("expected T01, got %s", detail.ID) + } + if detail.Content == "" { + t.Error("expected non-empty content") + } +} + +func TestAPIGetTask_NotFound(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/api/task/T99", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestAPIMoveTask(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=ready", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify file was moved + if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "ready", "T08.md")); err != nil { + t.Error("expected T08.md in ready/") + } + if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "backlog", "T08.md")); !os.IsNotExist(err) { + t.Error("expected T08.md removed from backlog/") + } +} + +func TestAPIMoveTask_NotFound(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/api/task/T99/move?to=ready", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestAPIMoveTask_InvalidFolder(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=invalid", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestAPIMoveTask_ForbiddenToActive(t *testing.T) { + srv := setupTestServer(t) + + // Put T08 in ready first + os.Rename( + filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), + filepath.Join(srv.Config.TasksDir, "ready", "T08.md"), + ) + + // Try to move ready → active (agent-only) + req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=active", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for ready→active, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestAPIMoveTask_ForbiddenActiveToReview(t *testing.T) { + srv := setupTestServer(t) + + // Put T08 in active + os.Rename( + filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), + filepath.Join(srv.Config.TasksDir, "active", "T08.md"), + ) + + // Try to move active → review (agent-only) + req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=review", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for active→review, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestAPIMoveTask_AllowedBacklogToReady(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=ready", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for backlog→ready, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestAPIMoveTask_AllowedReviewToDone(t *testing.T) { + srv := setupTestServer(t) + + // Put T08 in review + os.Rename( + filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), + filepath.Join(srv.Config.TasksDir, "review", "T08.md"), + ) + + req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=done", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for review→done, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestAPIMoveTask_AddsTimestamp(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=ready", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + content, _ := os.ReadFile(filepath.Join(srv.Config.TasksDir, "ready", "T08.md")) + text := string(content) + if !containsStr(text, "Odobren (→ready)") { + t.Error("expected timestamp in API-moved task file") + } +} diff --git a/code/internal/server/console.go b/code/internal/server/console.go index e27a915..dfa27d8 100644 --- a/code/internal/server/console.go +++ b/code/internal/server/console.go @@ -77,7 +77,13 @@ func (sm *taskSessionManager) startSession(taskID, sessionType, projectDir, prom log.Printf("Session[%s]: started (PID %d)", key, ptySess.Cmd.Process.Pid) - // Send the task prompt after claude initializes + // Write prompt to file, then send a one-liner to claude + // (multi-line prompt can't be typed into PTY — each \n submits early) + promptFile := filepath.Join(os.TempDir(), fmt.Sprintf("kaos-%s-prompt.txt", key)) + if err := os.WriteFile(promptFile, []byte(prompt), 0644); err != nil { + log.Printf("Session[%s]: failed to write prompt file: %v", key, err) + } + go func() { subID := fmt.Sprintf("init-%d", time.Now().UnixNano()) ch := ptySess.Subscribe(subID) @@ -99,9 +105,10 @@ func (sm *taskSessionManager) startSession(taskID, sessionType, projectDir, prom // Let claude fully render its welcome screen time.Sleep(2 * time.Second) - // Type the prompt - log.Printf("Session[%s]: sending prompt (%d bytes)", key, len(prompt)) - ptySess.WriteInput([]byte(prompt + "\n")) + // Send one-liner that tells claude to read the prompt file + oneliner := fmt.Sprintf("Pročitaj fajl %s i uradi SVE što piše unutra.", promptFile) + log.Printf("Session[%s]: sending prompt via file %s (%d bytes)", key, promptFile, len(prompt)) + ptySess.WriteInput([]byte(oneliner + "\n")) }() return sess, nil diff --git a/code/internal/server/console_test.go b/code/internal/server/console_test.go new file mode 100644 index 0000000..4a113ce --- /dev/null +++ b/code/internal/server/console_test.go @@ -0,0 +1,312 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestConsolePage(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/console", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + body := w.Body.String() + if !containsStr(body, "KAOS") { + t.Error("expected 'KAOS' in console page") + } + if !containsStr(body, "Konzola") { + t.Error("expected 'Konzola' in console page") + } +} + +func TestConsoleSessions_Empty(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/console/sessions", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var sessions []taskSessionResponse + if err := json.Unmarshal(w.Body.Bytes(), &sessions); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if len(sessions) != 0 { + t.Fatalf("expected 0 sessions, got %d", len(sessions)) + } +} + +func TestConsoleKill_NotFound(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/console/kill/T99", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestSessionKey(t *testing.T) { + if got := sessionKey("T01", "work"); got != "T01" { + t.Errorf("expected T01, got %s", got) + } + if got := sessionKey("T01", "review"); got != "T01-review" { + t.Errorf("expected T01-review, got %s", got) + } +} + +func TestTaskSessionManager_ListEmpty(t *testing.T) { + sm := newTaskSessionManager() + sessions := sm.listSessions() + if len(sessions) != 0 { + t.Errorf("expected 0 sessions, got %d", len(sessions)) + } +} + +func TestTaskSessionManager_KillNotFound(t *testing.T) { + sm := newTaskSessionManager() + if sm.killSession("T99", "work") { + t.Error("expected false for non-existent session") + } +} + +func TestTaskSessionManager_GetNotFound(t *testing.T) { + sm := newTaskSessionManager() + if sm.getSessionByKey("T99") != nil { + t.Error("expected nil for non-existent session") + } +} + +func TestBuildWorkPrompt(t *testing.T) { + prompt := buildWorkPrompt("T08", []byte("# T08: Test task\n\nOpis.")) + + if !containsStr(prompt, "T08") { + t.Error("expected task ID in prompt") + } + if !containsStr(prompt, "Test task") { + t.Error("expected task content in prompt") + } + if !containsStr(prompt, "agents/coder/CLAUDE.md") { + t.Error("expected coder CLAUDE.md reference") + } + if !containsStr(prompt, "go test") { + t.Error("expected test instruction") + } + if !containsStr(prompt, "report") { + t.Error("expected report instruction") + } +} + +func TestBuildReviewPrompt(t *testing.T) { + prompt := buildReviewPrompt("T08", []byte("# T08: Test\n"), []byte("# Report\nSve ok.")) + + if !containsStr(prompt, "T08") { + t.Error("expected task ID in review prompt") + } + if !containsStr(prompt, "Sve ok") { + t.Error("expected report content in review prompt") + } + if !containsStr(prompt, "agents/checker/CLAUDE.md") { + t.Error("expected checker CLAUDE.md reference") + } +} + +func TestBuildReviewPrompt_NoReport(t *testing.T) { + prompt := buildReviewPrompt("T08", []byte("# T08: Test\n"), nil) + + if !containsStr(prompt, "T08") { + t.Error("expected task ID") + } + if containsStr(prompt, "Izveštaj agenta") { + t.Error("should not include report section when no report") + } +} + +func TestReviewTask_NotFound(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/task/T99/review", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestConsolePage_HasKillButton(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/console", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "killSession") { + t.Error("expected killSession function in console page") + } +} + +func TestTaskDetail_HasProveriButton(t *testing.T) { + srv := setupTestServer(t) + + // Put T08 in review + os.Rename( + filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), + filepath.Join(srv.Config.TasksDir, "review", "T08.md"), + ) + + req := httptest.NewRequest(http.MethodGet, "/task/T08", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "Proveri") { + t.Error("expected 'Proveri' button for review task") + } + if !containsStr(body, "/review") { + t.Error("expected /review endpoint in Proveri button") + } +} + +func TestConsolePage_ToolbarAbovePanels(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/console", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + // Toolbar should appear before console-panels in the HTML + toolbarIdx := strings.Index(body, "console-toolbar") + panelsIdx := strings.Index(body, "console-panels") + + if toolbarIdx == -1 { + t.Fatal("expected console-toolbar in console page") + } + if panelsIdx == -1 { + t.Fatal("expected console-panels in console page") + } + if toolbarIdx > panelsIdx { + t.Error("expected toolbar before panels in console HTML") + } +} + +func TestConsolePage_HasDynamicSessions(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/console", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "refreshSessions") { + t.Error("expected refreshSessions function in console page") + } + if !containsStr(body, "/console/sessions") { + t.Error("expected /console/sessions API call") + } +} + +func TestConsolePage_HasXtermJS(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/console", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "xterm.min.js") { + t.Error("expected xterm.min.js CDN link in console page") + } + if !containsStr(body, "addon-fit") { + t.Error("expected addon-fit CDN link in console page") + } + if !containsStr(body, "xterm.css") { + t.Error("expected xterm.css CDN link in console page") + } +} + +func TestConsolePage_HasWebSocket(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/console", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "console/ws/") { + t.Error("expected WebSocket URL console/ws/ in console page") + } + if !containsStr(body, "new WebSocket") { + t.Error("expected WebSocket constructor in console page") + } +} + +func TestConsolePage_HasEmptyState(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/console", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "empty-state") { + t.Error("expected empty-state element") + } + if !containsStr(body, "Pusti") { + t.Error("expected 'Pusti' instruction in empty state") + } + if !containsStr(body, "console-terminal") { + t.Error("expected console-terminal class in JS code") + } +} + +func TestConsolePage_HasBinaryMessageSupport(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/console", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "arraybuffer") { + t.Error("expected arraybuffer binary type for WebSocket") + } + if !containsStr(body, "Uint8Array") { + t.Error("expected Uint8Array handling for binary messages") + } +} + +func TestConsolePage_HasResizeHandler(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/console", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "fitAddon.fit()") { + t.Error("expected fitAddon.fit() for resize handling") + } + if !containsStr(body, `'resize'`) { + t.Error("expected resize message type in WebSocket handler") + } +} diff --git a/code/internal/server/dashboard_test.go b/code/internal/server/dashboard_test.go new file mode 100644 index 0000000..a8887fb --- /dev/null +++ b/code/internal/server/dashboard_test.go @@ -0,0 +1,317 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestDashboardHTML(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + body := w.Body.String() + if !containsStr(body, "KAOS Dashboard") { + t.Error("expected 'KAOS Dashboard' in HTML") + } + if !containsStr(body, "T01") { + t.Error("expected T01 in HTML") + } + if !containsStr(body, "T08") { + t.Error("expected T08 in HTML") + } + if !containsStr(body, "BACKLOG") { + t.Error("expected BACKLOG column in HTML") + } + if !containsStr(body, "DONE") { + t.Error("expected DONE column in HTML") + } +} + +func TestHTMLMoveTask(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=ready", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Should return updated dashboard HTML + body := w.Body.String() + if !containsStr(body, "KAOS Dashboard") { + t.Error("expected dashboard HTML after move") + } +} + +func TestDashboardHTML_HasAllColumns(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + for _, col := range []string{"BACKLOG", "READY", "ACTIVE", "REVIEW", "DONE"} { + if !containsStr(body, col) { + t.Errorf("expected %s column in dashboard", col) + } + } +} + +func TestDashboardHTML_HasHTMXAttributes(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "hx-get") { + t.Error("expected hx-get attributes in HTML") + } + if !containsStr(body, "hx-target") { + t.Error("expected hx-target attributes in HTML") + } +} + +func TestDashboardHTML_TasksInCorrectColumns(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + // T01 should be in done column, T08 in backlog + if !containsStr(body, `id="col-done"`) { + t.Error("expected col-done in HTML") + } + if !containsStr(body, `id="col-backlog"`) { + t.Error("expected col-backlog in HTML") + } +} + +func TestIsMoveAllowed(t *testing.T) { + tests := []struct { + from, to string + allowed bool + }{ + {"backlog", "ready", true}, + {"ready", "backlog", true}, + {"review", "done", true}, + {"review", "ready", true}, + {"done", "review", true}, + {"ready", "active", false}, + {"active", "review", false}, + {"backlog", "done", false}, + {"backlog", "active", false}, + {"done", "backlog", false}, + {"ready", "ready", false}, + } + + for _, tt := range tests { + got := isMoveAllowed(tt.from, tt.to) + if got != tt.allowed { + t.Errorf("isMoveAllowed(%s, %s) = %v, want %v", tt.from, tt.to, got, tt.allowed) + } + } +} + +func TestNoCacheHeaders(t *testing.T) { + srv := setupTestServer(t) + + // Dynamic route should have no-cache headers + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + cc := w.Header().Get("Cache-Control") + if !containsStr(cc, "no-store") { + t.Errorf("expected Cache-Control no-store on dashboard, got %q", cc) + } + + // API route should also have no-cache headers + req2 := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) + w2 := httptest.NewRecorder() + srv.Router.ServeHTTP(w2, req2) + + cc2 := w2.Header().Get("Cache-Control") + if !containsStr(cc2, "no-store") { + t.Errorf("expected Cache-Control no-store on API, got %q", cc2) + } +} + +func TestDashboardReflectsDiskChanges(t *testing.T) { + srv := setupTestServer(t) + + // Initial state: T08 in backlog + req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + var tasks1 []taskResponse + json.Unmarshal(w.Body.Bytes(), &tasks1) + + found := false + for _, task := range tasks1 { + if task.ID == "T08" && task.Status == "backlog" { + found = true + } + } + if !found { + t.Fatal("expected T08 in backlog initially") + } + + // Move file on disk (simulating external change) + os.Rename( + filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), + filepath.Join(srv.Config.TasksDir, "ready", "T08.md"), + ) + + // Second request should reflect the change without server restart + req2 := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) + w2 := httptest.NewRecorder() + srv.Router.ServeHTTP(w2, req2) + + var tasks2 []taskResponse + json.Unmarshal(w2.Body.Bytes(), &tasks2) + + found = false + for _, task := range tasks2 { + if task.ID == "T08" && task.Status == "ready" { + found = true + } + } + if !found { + t.Fatal("expected T08 in ready after disk move — server did not read fresh state") + } +} + +func TestDashboardHTML_HasSortableScript(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "sortable.min.js") { + t.Error("expected sortable.min.js script tag") + } + if !containsStr(body, "initSortable") { + t.Error("expected initSortable function") + } +} + +func TestDashboardHTML_HasDataFolderAttributes(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, `data-folder="backlog"`) { + t.Error("expected data-folder attribute on column-tasks") + } + if !containsStr(body, `data-folder="ready"`) { + t.Error("expected data-folder=ready attribute") + } +} + +func TestTaskDetail_HasMoveButtons(t *testing.T) { + srv := setupTestServer(t) + + // T08 is in backlog, should have "Odobri" button + req := httptest.NewRequest(http.MethodGet, "/task/T08", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + body := w.Body.String() + if !containsStr(body, "Odobri") { + t.Error("expected 'Odobri' button for backlog task") + } +} + +func TestDashboardHTML_HasRunButton(t *testing.T) { + srv := setupTestServer(t) + + // Move T08 to ready to test run button + os.Rename( + filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), + filepath.Join(srv.Config.TasksDir, "ready", "T08.md"), + ) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "Pusti") { + t.Error("expected 'Pusti' button for ready task") + } + if !containsStr(body, "btn-run") { + t.Error("expected btn-run class") + } +} + +func TestDashboardHTML_BlockedButton(t *testing.T) { + srv := setupTestServer(t) + + // T08 in backlog with dep T07 not in done → blocked + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "Blokiran") { + t.Error("expected 'Blokiran' for backlog task with unmet deps") + } + if !containsStr(body, "btn-blocked") { + t.Error("expected btn-blocked class") + } +} + +func TestDashboardHTML_DoneReportButton(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + // T01 is in done → should have "Izvestaj" button + if !containsStr(body, "btn-report") { + t.Error("expected btn-report for done task") + } +} + +func TestDashboard_HasPrijavaNav(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, `href="/submit"`) { + t.Error("expected Prijava nav link in dashboard") + } +} diff --git a/code/internal/server/docs_test.go b/code/internal/server/docs_test.go new file mode 100644 index 0000000..1421ad0 --- /dev/null +++ b/code/internal/server/docs_test.go @@ -0,0 +1,201 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestDocsList(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/docs", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if !containsStr(body, "CLAUDE.md") { + t.Error("expected CLAUDE.md in docs list") + } + if !containsStr(body, "README.md") { + t.Error("expected README.md in docs list") + } + if !containsStr(body, "agents/coder/CLAUDE.md") { + t.Error("expected agents/coder/CLAUDE.md in docs list") + } +} + +func TestDocsView_CLAUDE(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/docs/CLAUDE.md", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if !containsStr(body, "Glavni fajl") { + t.Error("expected rendered markdown content") + } + // Should have table rendered as HTML + if !containsStr(body, "") || !containsStr(body, "
") { + t.Error("expected HTML table from markdown") + } +} + +func TestDocsView_NestedFile(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/docs/agents/coder/CLAUDE.md", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if !containsStr(body, "Coder Agent") { + t.Error("expected nested file content") + } + // Breadcrumbs + if !containsStr(body, "agents") { + t.Error("expected breadcrumb for agents") + } +} + +func TestDocsView_PathTraversal(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/docs/../../etc/passwd", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for path traversal, got %d", w.Code) + } +} + +func TestDocsView_NonMarkdown(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/docs/main.go", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for non-.md file, got %d", w.Code) + } +} + +func TestDocsView_NotFound(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/docs/nonexistent.md", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestDocsView_HasBreadcrumbs(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/docs/agents/coder/CLAUDE.md", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "Dokumenti") { + t.Error("expected 'Dokumenti' in breadcrumbs") + } + if !containsStr(body, "coder") { + t.Error("expected 'coder' in breadcrumbs") + } +} + +func TestDocsView_HasSidebarLayout(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/docs/CLAUDE.md", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "docs-layout") { + t.Error("expected docs-layout class for grid layout") + } + if !containsStr(body, "docs-sidebar") { + t.Error("expected docs-sidebar class") + } + if !containsStr(body, "docs-main") { + t.Error("expected docs-main class") + } + // Sidebar should list files + if !containsStr(body, "README.md") { + t.Error("expected file list in sidebar") + } +} + +func TestDocsView_HTMXReturnsFragment(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/docs/CLAUDE.md", nil) + req.Header.Set("HX-Request", "true") + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + // Should NOT have full page HTML + if containsStr(body, "") { + t.Error("HTMX request should return fragment, not full page") + } + // Should have breadcrumbs and content + if !containsStr(body, "Dokumenti") { + t.Error("expected breadcrumbs in fragment") + } + if !containsStr(body, "Glavni fajl") { + t.Error("expected rendered content in fragment") + } +} + +func TestDocsList_HasSidebarLayout(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/docs", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "docs-layout") { + t.Error("expected docs-layout class on docs list page") + } +} + +func TestRewriteLinksSimple(t *testing.T) { + input := `link and ext` + result := rewriteLinksSimple(input, ".") + if !containsStr(result, `/docs/README.md`) { + t.Errorf("expected rewritten link, got: %s", result) + } + if !containsStr(result, `https://example.com`) { + t.Error("external link should not be rewritten") + } +} + +func TestRewriteLinksSimple_NestedDir(t *testing.T) { + input := `link` + result := rewriteLinksSimple(input, "agents/coder") + if !containsStr(result, `/docs/agents/coder/CLAUDE.md`) { + t.Errorf("expected nested rewritten link, got: %s", result) + } +} diff --git a/code/internal/server/logs.go b/code/internal/server/logs.go new file mode 100644 index 0000000..e5bf45a --- /dev/null +++ b/code/internal/server/logs.go @@ -0,0 +1,41 @@ +package server + +import ( + "net/http" + "os" + "strings" + + "github.com/gin-gonic/gin" +) + +const maxLogLines = 20 + +// handleLogsTail returns the last 20 lines of the server log file as plain text. +func (s *Server) handleLogsTail(c *gin.Context) { + if s.Config.LogFile == "" { + c.String(http.StatusOK, "KAOS_LOG_FILE nije podešen. Podesi env varijablu za prikaz logova.") + return + } + + data, err := os.ReadFile(s.Config.LogFile) + if err != nil { + if os.IsNotExist(err) { + c.String(http.StatusOK, "Log fajl ne postoji: "+s.Config.LogFile) + return + } + c.String(http.StatusInternalServerError, "Greška pri čitanju loga: "+err.Error()) + return + } + + lines := tailLines(string(data), maxLogLines) + c.String(http.StatusOK, strings.Join(lines, "\n")) +} + +// tailLines returns the last n non-empty lines from text. +func tailLines(text string, n int) []string { + allLines := strings.Split(strings.TrimRight(text, "\n"), "\n") + if len(allLines) <= n { + return allLines + } + return allLines[len(allLines)-n:] +} diff --git a/code/internal/server/search_test.go b/code/internal/server/search_test.go new file mode 100644 index 0000000..e75c163 --- /dev/null +++ b/code/internal/server/search_test.go @@ -0,0 +1,117 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestSearch_FindsTask(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/search?q=Prvi", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + body := w.Body.String() + if !containsStr(body, "T01") { + t.Error("expected T01 in search results for 'Prvi'") + } +} + +func TestSearch_FindsTaskByID(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/search?q=T08", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "T08") { + t.Error("expected T08 in search results") + } +} + +func TestSearch_FindsDocument(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/search?q=checker", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "checker/CLAUDE.md") { + t.Error("expected checker CLAUDE.md in search results") + } +} + +func TestSearch_FindsReport(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/search?q=prolaze", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "T01-report.md") { + t.Error("expected T01 report in search results") + } +} + +func TestSearch_EmptyQuery(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/search?q=", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if w.Body.Len() != 0 { + t.Errorf("expected empty response for empty query, got %d bytes", w.Body.Len()) + } +} + +func TestSearch_NoResults(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/search?q=xyznepostoji", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "Nema rezultata") { + t.Error("expected 'Nema rezultata' message") + } +} + +func TestSearch_CaseInsensitive(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/search?q=CODER", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "coder") { + t.Error("expected case-insensitive match for 'CODER'") + } +} + +func TestSearch_HasSnippet(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/search?q=kodiranja", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "kodiranja") { + t.Error("expected snippet with 'kodiranja' text") + } +} diff --git a/code/internal/server/server_test.go b/code/internal/server/server_test.go deleted file mode 100644 index 9bfa33b..0000000 --- a/code/internal/server/server_test.go +++ /dev/null @@ -1,2165 +0,0 @@ -package server - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/dal/kaos/internal/config" - "github.com/dal/kaos/internal/supervisor" -) - -const testTask1 = `# T01: Prvi task - -**Agent:** coder -**Model:** Sonnet -**Zavisi od:** — - ---- - -## Opis - -Opis prvog taska. - ---- -` - -const testTask2 = `# T08: HTTP server - -**Agent:** coder -**Model:** Sonnet -**Zavisi od:** T07 - ---- - -## Opis - -Implementacija HTTP servera. - ---- -` - -func setupTestServer(t *testing.T) *Server { - t.Helper() - dir := t.TempDir() - - tasksDir := filepath.Join(dir, "TASKS") - for _, folder := range []string{"backlog", "ready", "active", "review", "done", "reports"} { - os.MkdirAll(filepath.Join(tasksDir, folder), 0755) - } - - os.WriteFile(filepath.Join(tasksDir, "done", "T01.md"), []byte(testTask1), 0644) - os.WriteFile(filepath.Join(tasksDir, "backlog", "T08.md"), []byte(testTask2), 0644) - - // Docs: create markdown files in project root - os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte("# CLAUDE.md\n\nGlavni fajl.\n\n| Kolona | Opis |\n|--------|------|\n| A | B |\n"), 0644) - os.WriteFile(filepath.Join(dir, "README.md"), []byte("# README\n\nOpis projekta.\n"), 0644) - os.MkdirAll(filepath.Join(dir, "agents", "coder"), 0755) - os.WriteFile(filepath.Join(dir, "agents", "coder", "CLAUDE.md"), []byte("# Coder Agent\n\nPravila kodiranja.\n"), 0644) - os.MkdirAll(filepath.Join(dir, "agents", "checker"), 0755) - os.WriteFile(filepath.Join(dir, "agents", "checker", "CLAUDE.md"), []byte("# Checker Agent\n\nBuild + Test verifikacija.\n"), 0644) - os.WriteFile(filepath.Join(tasksDir, "reports", "T01-report.md"), []byte("# T01 Report\n\n10 testova, svi prolaze.\n"), 0644) - - cfg := &config.Config{ - TasksDir: tasksDir, - ProjectPath: dir, - Port: "0", - } - - return New(cfg) -} - -func TestAPIGetTasks(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - var tasks []taskResponse - if err := json.Unmarshal(w.Body.Bytes(), &tasks); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - - if len(tasks) != 2 { - t.Fatalf("expected 2 tasks, got %d", len(tasks)) - } - - // Check that tasks have correct statuses - statuses := map[string]string{} - for _, task := range tasks { - statuses[task.ID] = task.Status - } - if statuses["T01"] != "done" { - t.Errorf("expected T01 status done, got %s", statuses["T01"]) - } - if statuses["T08"] != "backlog" { - t.Errorf("expected T08 status backlog, got %s", statuses["T08"]) - } -} - -func TestAPIGetTask(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/api/task/T01", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - var detail taskDetailResponse - if err := json.Unmarshal(w.Body.Bytes(), &detail); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - - if detail.ID != "T01" { - t.Errorf("expected T01, got %s", detail.ID) - } - if detail.Content == "" { - t.Error("expected non-empty content") - } -} - -func TestAPIGetTask_NotFound(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/api/task/T99", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d", w.Code) - } -} - -func TestAPIMoveTask(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=ready", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - // Verify file was moved - if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "ready", "T08.md")); err != nil { - t.Error("expected T08.md in ready/") - } - if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "backlog", "T08.md")); !os.IsNotExist(err) { - t.Error("expected T08.md removed from backlog/") - } -} - -func TestAPIMoveTask_NotFound(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodPost, "/api/task/T99/move?to=ready", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d", w.Code) - } -} - -func TestAPIMoveTask_InvalidFolder(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=invalid", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", w.Code) - } -} - -func TestDashboardHTML(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - body := w.Body.String() - if !containsStr(body, "KAOS Dashboard") { - t.Error("expected 'KAOS Dashboard' in HTML") - } - if !containsStr(body, "T01") { - t.Error("expected T01 in HTML") - } - if !containsStr(body, "T08") { - t.Error("expected T08 in HTML") - } - if !containsStr(body, "BACKLOG") { - t.Error("expected BACKLOG column in HTML") - } - if !containsStr(body, "DONE") { - t.Error("expected DONE column in HTML") - } -} - -func TestTaskDetailHTML(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/task/T01", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - body := w.Body.String() - if !containsStr(body, "T01") { - t.Error("expected T01 in detail HTML") - } - if !containsStr(body, "Prvi task") { - t.Error("expected task title in detail HTML") - } -} - -func TestTaskDetailHTML_NotFound(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/task/T99", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d", w.Code) - } -} - -func TestHTMLMoveTask(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=ready", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - // Should return updated dashboard HTML - body := w.Body.String() - if !containsStr(body, "KAOS Dashboard") { - t.Error("expected dashboard HTML after move") - } -} - -func TestDashboardHTML_HasAllColumns(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - for _, col := range []string{"BACKLOG", "READY", "ACTIVE", "REVIEW", "DONE"} { - if !containsStr(body, col) { - t.Errorf("expected %s column in dashboard", col) - } - } -} - -func TestDashboardHTML_HasHTMXAttributes(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "hx-get") { - t.Error("expected hx-get attributes in HTML") - } - if !containsStr(body, "hx-target") { - t.Error("expected hx-target attributes in HTML") - } -} - -func TestDashboardHTML_TasksInCorrectColumns(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - // T01 should be in done column, T08 in backlog - if !containsStr(body, `id="col-done"`) { - t.Error("expected col-done in HTML") - } - if !containsStr(body, `id="col-backlog"`) { - t.Error("expected col-backlog in HTML") - } -} - -func TestReport_Exists(t *testing.T) { - srv := setupTestServer(t) - - // Create a report - reportsDir := filepath.Join(srv.Config.TasksDir, "reports") - os.MkdirAll(reportsDir, 0755) - os.WriteFile(filepath.Join(reportsDir, "T01-report.md"), []byte("# T01 Report\nSve ok."), 0644) - - req := httptest.NewRequest(http.MethodGet, "/report/T01", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - if !containsStr(w.Body.String(), "T01 Report") { - t.Error("expected report content") - } -} - -func TestReport_NotFound(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/report/T99", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d", w.Code) - } -} - -func TestTaskDetail_HasMoveButtons(t *testing.T) { - srv := setupTestServer(t) - - // T08 is in backlog, should have "Odobri" button - req := httptest.NewRequest(http.MethodGet, "/task/T08", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - body := w.Body.String() - if !containsStr(body, "Odobri") { - t.Error("expected 'Odobri' button for backlog task") - } -} - -func TestAPIMoveTask_ForbiddenToActive(t *testing.T) { - srv := setupTestServer(t) - - // Put T08 in ready first - os.Rename( - filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), - filepath.Join(srv.Config.TasksDir, "ready", "T08.md"), - ) - - // Try to move ready → active (agent-only) - req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=active", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusForbidden { - t.Fatalf("expected 403 for ready→active, got %d: %s", w.Code, w.Body.String()) - } -} - -func TestAPIMoveTask_ForbiddenActiveToReview(t *testing.T) { - srv := setupTestServer(t) - - // Put T08 in active - os.Rename( - filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), - filepath.Join(srv.Config.TasksDir, "active", "T08.md"), - ) - - // Try to move active → review (agent-only) - req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=review", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusForbidden { - t.Fatalf("expected 403 for active→review, got %d: %s", w.Code, w.Body.String()) - } -} - -func TestAPIMoveTask_AllowedBacklogToReady(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=ready", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 for backlog→ready, got %d: %s", w.Code, w.Body.String()) - } -} - -func TestAPIMoveTask_AllowedReviewToDone(t *testing.T) { - srv := setupTestServer(t) - - // Put T08 in review - os.Rename( - filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), - filepath.Join(srv.Config.TasksDir, "review", "T08.md"), - ) - - req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=done", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 for review→done, got %d: %s", w.Code, w.Body.String()) - } -} - -func TestDashboardHTML_HasSortableScript(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "sortable.min.js") { - t.Error("expected sortable.min.js script tag") - } - if !containsStr(body, "initSortable") { - t.Error("expected initSortable function") - } -} - -func TestDashboardHTML_HasDataFolderAttributes(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, `data-folder="backlog"`) { - t.Error("expected data-folder attribute on column-tasks") - } - if !containsStr(body, `data-folder="ready"`) { - t.Error("expected data-folder=ready attribute") - } -} - -func TestIsMoveAllowed(t *testing.T) { - tests := []struct { - from, to string - allowed bool - }{ - {"backlog", "ready", true}, - {"ready", "backlog", true}, - {"review", "done", true}, - {"review", "ready", true}, - {"done", "review", true}, - {"ready", "active", false}, - {"active", "review", false}, - {"backlog", "done", false}, - {"backlog", "active", false}, - {"done", "backlog", false}, - {"ready", "ready", false}, - } - - for _, tt := range tests { - got := isMoveAllowed(tt.from, tt.to) - if got != tt.allowed { - t.Errorf("isMoveAllowed(%s, %s) = %v, want %v", tt.from, tt.to, got, tt.allowed) - } - } -} - -func TestNoCacheHeaders(t *testing.T) { - srv := setupTestServer(t) - - // Dynamic route should have no-cache headers - req := httptest.NewRequest(http.MethodGet, "/", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - cc := w.Header().Get("Cache-Control") - if !containsStr(cc, "no-store") { - t.Errorf("expected Cache-Control no-store on dashboard, got %q", cc) - } - - // API route should also have no-cache headers - req2 := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) - w2 := httptest.NewRecorder() - srv.Router.ServeHTTP(w2, req2) - - cc2 := w2.Header().Get("Cache-Control") - if !containsStr(cc2, "no-store") { - t.Errorf("expected Cache-Control no-store on API, got %q", cc2) - } -} - -func TestDashboardReflectsDiskChanges(t *testing.T) { - srv := setupTestServer(t) - - // Initial state: T08 in backlog - req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - var tasks1 []taskResponse - json.Unmarshal(w.Body.Bytes(), &tasks1) - - found := false - for _, task := range tasks1 { - if task.ID == "T08" && task.Status == "backlog" { - found = true - } - } - if !found { - t.Fatal("expected T08 in backlog initially") - } - - // Move file on disk (simulating external change) - os.Rename( - filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), - filepath.Join(srv.Config.TasksDir, "ready", "T08.md"), - ) - - // Second request should reflect the change without server restart - req2 := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) - w2 := httptest.NewRecorder() - srv.Router.ServeHTTP(w2, req2) - - var tasks2 []taskResponse - json.Unmarshal(w2.Body.Bytes(), &tasks2) - - found = false - for _, task := range tasks2 { - if task.ID == "T08" && task.Status == "ready" { - found = true - } - } - if !found { - t.Fatal("expected T08 in ready after disk move — server did not read fresh state") - } -} - -func TestDocsList(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/docs", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - body := w.Body.String() - if !containsStr(body, "CLAUDE.md") { - t.Error("expected CLAUDE.md in docs list") - } - if !containsStr(body, "README.md") { - t.Error("expected README.md in docs list") - } - if !containsStr(body, "agents/coder/CLAUDE.md") { - t.Error("expected agents/coder/CLAUDE.md in docs list") - } -} - -func TestDocsView_CLAUDE(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/docs/CLAUDE.md", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - body := w.Body.String() - if !containsStr(body, "Glavni fajl") { - t.Error("expected rendered markdown content") - } - // Should have table rendered as HTML - if !containsStr(body, "") || !containsStr(body, "
") { - t.Error("expected HTML table from markdown") - } -} - -func TestDocsView_NestedFile(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/docs/agents/coder/CLAUDE.md", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - body := w.Body.String() - if !containsStr(body, "Coder Agent") { - t.Error("expected nested file content") - } - // Breadcrumbs - if !containsStr(body, "agents") { - t.Error("expected breadcrumb for agents") - } -} - -func TestDocsView_PathTraversal(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/docs/../../etc/passwd", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusForbidden { - t.Fatalf("expected 403 for path traversal, got %d", w.Code) - } -} - -func TestDocsView_NonMarkdown(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/docs/main.go", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusForbidden { - t.Fatalf("expected 403 for non-.md file, got %d", w.Code) - } -} - -func TestDocsView_NotFound(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/docs/nonexistent.md", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d", w.Code) - } -} - -func TestDocsView_HasBreadcrumbs(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/docs/agents/coder/CLAUDE.md", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "Dokumenti") { - t.Error("expected 'Dokumenti' in breadcrumbs") - } - if !containsStr(body, "coder") { - t.Error("expected 'coder' in breadcrumbs") - } -} - -func TestSearch_FindsTask(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/search?q=Prvi", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - body := w.Body.String() - if !containsStr(body, "T01") { - t.Error("expected T01 in search results for 'Prvi'") - } -} - -func TestSearch_FindsTaskByID(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/search?q=T08", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "T08") { - t.Error("expected T08 in search results") - } -} - -func TestSearch_FindsDocument(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/search?q=checker", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "checker/CLAUDE.md") { - t.Error("expected checker CLAUDE.md in search results") - } -} - -func TestSearch_FindsReport(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/search?q=prolaze", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "T01-report.md") { - t.Error("expected T01 report in search results") - } -} - -func TestSearch_EmptyQuery(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/search?q=", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - if w.Body.Len() != 0 { - t.Errorf("expected empty response for empty query, got %d bytes", w.Body.Len()) - } -} - -func TestSearch_NoResults(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/search?q=xyznepostoji", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "Nema rezultata") { - t.Error("expected 'Nema rezultata' message") - } -} - -func TestSearch_CaseInsensitive(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/search?q=CODER", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "coder") { - t.Error("expected case-insensitive match for 'CODER'") - } -} - -func TestSearch_HasSnippet(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/search?q=kodiranja", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "kodiranja") { - t.Error("expected snippet with 'kodiranja' text") - } -} - -func TestRunTask_Ready(t *testing.T) { - srv := setupTestServer(t) - - // Move T08 to ready first - os.Rename( - filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), - filepath.Join(srv.Config.TasksDir, "ready", "T08.md"), - ) - - req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - if resp["status"] != "started" { - t.Errorf("expected status started, got %v", resp["status"]) - } - if resp["task"] != "T08" { - t.Errorf("expected task T08, got %v", resp["task"]) - } - // Verify task moved to active/ - if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "active", "T08.md")); err != nil { - t.Error("expected T08.md in active/") - } -} - -func TestRunTask_BacklogWithDeps(t *testing.T) { - srv := setupTestServer(t) - - // T08 depends on T07, T01 is in done - // T08 depends on T07 which is NOT in done → should fail - req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400 for unmet deps, got %d: %s", w.Code, w.Body.String()) - } -} - -func TestRunTask_AlreadyDone(t *testing.T) { - srv := setupTestServer(t) - - // T01 is in done - req := httptest.NewRequest(http.MethodPost, "/task/T01/run", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400 for done task, got %d", w.Code) - } -} - -func TestRunTask_NotFound(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodPost, "/task/T99/run", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d", w.Code) - } -} - -func TestRunTask_MovesToActive(t *testing.T) { - srv := setupTestServer(t) - - // Move T08 to ready - os.Rename( - filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), - filepath.Join(srv.Config.TasksDir, "ready", "T08.md"), - ) - - req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - // Verify task moved to active/ - if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "active", "T08.md")); err != nil { - t.Error("expected T08.md in active/ after run") - } -} - -func TestDashboardHTML_HasRunButton(t *testing.T) { - srv := setupTestServer(t) - - // Move T08 to ready to test run button - os.Rename( - filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), - filepath.Join(srv.Config.TasksDir, "ready", "T08.md"), - ) - - req := httptest.NewRequest(http.MethodGet, "/", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "Pusti") { - t.Error("expected 'Pusti' button for ready task") - } - if !containsStr(body, "btn-run") { - t.Error("expected btn-run class") - } -} - -func TestResolveTaskAction_Blocked(t *testing.T) { - doneSet := map[string]bool{"T01": true} - task := supervisor.Task{ID: "T08", Status: "backlog", DependsOn: []string{"T07"}} - if got := resolveTaskAction(task, doneSet); got != "blocked" { - t.Errorf("expected blocked, got %s", got) - } -} - -func TestResolveTaskAction_Review(t *testing.T) { - doneSet := map[string]bool{"T07": true} - task := supervisor.Task{ID: "T08", Status: "backlog", DependsOn: []string{"T07"}} - if got := resolveTaskAction(task, doneSet); got != "review" { - t.Errorf("expected review, got %s", got) - } -} - -func TestResolveTaskAction_Run(t *testing.T) { - task := supervisor.Task{ID: "T08", Status: "ready"} - if got := resolveTaskAction(task, nil); got != "run" { - t.Errorf("expected run, got %s", got) - } -} - -func TestResolveTaskAction_Running(t *testing.T) { - task := supervisor.Task{ID: "T08", Status: "active"} - if got := resolveTaskAction(task, nil); got != "running" { - t.Errorf("expected running, got %s", got) - } -} - -func TestResolveTaskAction_Approve(t *testing.T) { - task := supervisor.Task{ID: "T08", Status: "review"} - if got := resolveTaskAction(task, nil); got != "approve" { - t.Errorf("expected approve, got %s", got) - } -} - -func TestResolveTaskAction_Done(t *testing.T) { - task := supervisor.Task{ID: "T01", Status: "done"} - if got := resolveTaskAction(task, nil); got != "done" { - t.Errorf("expected done, got %s", got) - } -} - -func TestDashboardHTML_BlockedButton(t *testing.T) { - srv := setupTestServer(t) - - // T08 in backlog with dep T07 not in done → blocked - req := httptest.NewRequest(http.MethodGet, "/", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "Blokiran") { - t.Error("expected 'Blokiran' for backlog task with unmet deps") - } - if !containsStr(body, "btn-blocked") { - t.Error("expected btn-blocked class") - } -} - -func TestDashboardHTML_DoneReportButton(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - // T01 is in done → should have "Izvestaj" button - if !containsStr(body, "btn-report") { - t.Error("expected btn-report for done task") - } -} - -func TestSSE_EventsEndpoint(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/events", nil) - w := httptest.NewRecorder() - - // Use a context with cancel to stop the SSE handler - ctx, cancel := req.Context(), func() {} - _ = ctx - _ = cancel - - // Just check the handler starts without error and sets correct headers - go func() { - srv.Router.ServeHTTP(w, req) - }() - - time.Sleep(100 * time.Millisecond) - - if ct := w.Header().Get("Content-Type"); ct != "text/event-stream" { - t.Errorf("expected Content-Type text/event-stream, got %s", ct) - } -} - -func TestHashTaskState(t *testing.T) { - tasks1 := []supervisor.Task{ - {ID: "T01", Status: "done"}, - {ID: "T02", Status: "backlog"}, - } - tasks2 := []supervisor.Task{ - {ID: "T01", Status: "done"}, - {ID: "T02", Status: "ready"}, // changed - } - tasks3 := []supervisor.Task{ - {ID: "T02", Status: "backlog"}, - {ID: "T01", Status: "done"}, // same as tasks1 but different order - } - - h1 := hashTaskState(tasks1) - h2 := hashTaskState(tasks2) - h3 := hashTaskState(tasks3) - - if h1 == h2 { - t.Error("hash should differ when task status changes") - } - if h1 != h3 { - t.Error("hash should be same regardless of task order") - } -} - -func TestSSE_BroadcastOnChange(t *testing.T) { - srv := setupTestServer(t) - - // Subscribe a client - ch := srv.events.subscribe() - defer srv.events.unsubscribe(ch) - - // Trigger a check — first call sets the baseline hash and broadcasts - srv.events.checkAndBroadcast(func() string { return "board-html" }) - - // Drain initial broadcast - select { - case <-ch: - case <-time.After(100 * time.Millisecond): - } - - // Move a task to change state - os.Rename( - filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), - filepath.Join(srv.Config.TasksDir, "ready", "T08.md"), - ) - - // Trigger another check — state changed, should broadcast - srv.events.checkAndBroadcast(func() string { return "updated-board" }) - - select { - case data := <-ch: - if data != "updated-board" { - t.Errorf("expected 'updated-board', got %s", data) - } - case <-time.After(time.Second): - t.Error("expected broadcast after state change, got nothing") - } -} - -func TestSSE_NoBroadcastWithoutChange(t *testing.T) { - srv := setupTestServer(t) - - ch := srv.events.subscribe() - defer srv.events.unsubscribe(ch) - - // Two checks without changes — second should not broadcast - srv.events.checkAndBroadcast(func() string { return "board" }) - - // Drain the first broadcast (initial hash set) - select { - case <-ch: - case <-time.After(100 * time.Millisecond): - } - - srv.events.checkAndBroadcast(func() string { return "board" }) - - select { - case <-ch: - t.Error("should not broadcast when state hasn't changed") - case <-time.After(100 * time.Millisecond): - // Good — no broadcast - } -} - -func TestConsolePage(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/console", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - body := w.Body.String() - if !containsStr(body, "KAOS") { - t.Error("expected 'KAOS' in console page") - } - if !containsStr(body, "Konzola") { - t.Error("expected 'Konzola' in console page") - } -} - -func TestConsoleSessions_Empty(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/console/sessions", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - var sessions []taskSessionResponse - if err := json.Unmarshal(w.Body.Bytes(), &sessions); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - - if len(sessions) != 0 { - t.Fatalf("expected 0 sessions, got %d", len(sessions)) - } -} - -func TestConsoleKill_NotFound(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodPost, "/console/kill/T99", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d", w.Code) - } -} - -func TestDocsView_HasSidebarLayout(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/docs/CLAUDE.md", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "docs-layout") { - t.Error("expected docs-layout class for grid layout") - } - if !containsStr(body, "docs-sidebar") { - t.Error("expected docs-sidebar class") - } - if !containsStr(body, "docs-main") { - t.Error("expected docs-main class") - } - // Sidebar should list files - if !containsStr(body, "README.md") { - t.Error("expected file list in sidebar") - } -} - -func TestDocsView_HTMXReturnsFragment(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/docs/CLAUDE.md", nil) - req.Header.Set("HX-Request", "true") - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - // Should NOT have full page HTML - if containsStr(body, "") { - t.Error("HTMX request should return fragment, not full page") - } - // Should have breadcrumbs and content - if !containsStr(body, "Dokumenti") { - t.Error("expected breadcrumbs in fragment") - } - if !containsStr(body, "Glavni fajl") { - t.Error("expected rendered content in fragment") - } -} - -func TestDocsList_HasSidebarLayout(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/docs", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "docs-layout") { - t.Error("expected docs-layout class on docs list page") - } -} - -func TestRewriteLinksSimple(t *testing.T) { - input := `link and ext` - result := rewriteLinksSimple(input, ".") - if !containsStr(result, `/docs/README.md`) { - t.Errorf("expected rewritten link, got: %s", result) - } - if !containsStr(result, `https://example.com`) { - t.Error("external link should not be rewritten") - } -} - -func TestRewriteLinksSimple_NestedDir(t *testing.T) { - input := `link` - result := rewriteLinksSimple(input, "agents/coder") - if !containsStr(result, `/docs/agents/coder/CLAUDE.md`) { - t.Errorf("expected nested rewritten link, got: %s", result) - } -} - -// --- T22: Submit tests --- - -func TestSubmitPage(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/submit", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - body := w.Body.String() - if !containsStr(body, "Klijent") { - t.Error("expected 'Klijent' mode button") - } - if !containsStr(body, "Operater") { - t.Error("expected 'Operater' mode button") - } - if !containsStr(body, "mode-client") { - t.Error("expected client mode section") - } -} - -func TestSubmitPage_ClientModeIsDefault(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/submit", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - // Operator mode should be hidden by default - if !containsStr(body, `id="mode-operator" class="submit-mode" style="display:none"`) { - t.Error("expected operator mode to be hidden by default") - } -} - -func TestSimpleSubmit_CreatesTask(t *testing.T) { - srv := setupTestServer(t) - - form := strings.NewReader("title=Test+prijava&description=Opis+testa&priority=Visok") - req := httptest.NewRequest(http.MethodPost, "/submit/simple", form) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - - if resp["status"] != "ok" { - t.Errorf("expected status ok, got %v", resp["status"]) - } - - taskID, ok := resp["task_id"].(string) - if !ok || taskID == "" { - t.Fatal("expected non-empty task_id") - } - - // Verify file was created in backlog - path := filepath.Join(srv.Config.TasksDir, "backlog", taskID+".md") - content, err := os.ReadFile(path) - if err != nil { - t.Fatalf("expected task file in backlog: %v", err) - } - - if !containsStr(string(content), "Test prijava") { - t.Error("expected title in task file") - } - if !containsStr(string(content), "Visok") { - t.Error("expected priority in task file") - } - if !containsStr(string(content), "klijent (prijava)") { - t.Error("expected 'klijent (prijava)' as creator") - } -} - -func TestSimpleSubmit_MissingTitle(t *testing.T) { - srv := setupTestServer(t) - - form := strings.NewReader("description=Samo+opis") - req := httptest.NewRequest(http.MethodPost, "/submit/simple", form) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400 for missing title, got %d", w.Code) - } -} - -func TestSimpleSubmit_AutoNumbering(t *testing.T) { - srv := setupTestServer(t) - - // Existing tasks: T01 (done), T08 (backlog) - // Next should be T09 - - form := strings.NewReader("title=Novi+task&priority=Srednji") - req := httptest.NewRequest(http.MethodPost, "/submit/simple", form) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - - taskID, _ := resp["task_id"].(string) - if taskID != "T09" { - t.Errorf("expected T09 (next after T08), got %s", taskID) - } -} - -func TestSimpleSubmit_DefaultPriority(t *testing.T) { - srv := setupTestServer(t) - - form := strings.NewReader("title=Bez+prioriteta") - req := httptest.NewRequest(http.MethodPost, "/submit/simple", form) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - - taskID, _ := resp["task_id"].(string) - path := filepath.Join(srv.Config.TasksDir, "backlog", taskID+".md") - content, _ := os.ReadFile(path) - - if !containsStr(string(content), "Srednji") { - t.Error("expected default priority 'Srednji'") - } -} - -func TestChatSubmit_ReturnsChatID(t *testing.T) { - srv := setupTestServer(t) - - body := `{"message":"test poruka"}` - req := httptest.NewRequest(http.MethodPost, "/submit/chat", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - if resp["chat_id"] == nil || resp["chat_id"] == "" { - t.Error("expected non-empty chat_id in response") - } -} - -func TestChatSubmit_EmptyMessage(t *testing.T) { - srv := setupTestServer(t) - - body := `{"message":""}` - req := httptest.NewRequest(http.MethodPost, "/submit/chat", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400 for empty message, got %d", w.Code) - } -} - -func TestChatStream_NotFound(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/submit/chat/stream/nonexistent", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d", w.Code) - } -} - -func TestNextTaskNumber(t *testing.T) { - dir := t.TempDir() - tasksDir := filepath.Join(dir, "TASKS") - for _, f := range []string{"backlog", "ready", "active", "review", "done"} { - os.MkdirAll(filepath.Join(tasksDir, f), 0755) - } - - os.WriteFile(filepath.Join(tasksDir, "done", "T01.md"), []byte(testTask1), 0644) - os.WriteFile(filepath.Join(tasksDir, "backlog", "T08.md"), []byte(testTask2), 0644) - - num, err := nextTaskNumber(tasksDir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if num != "T09" { - t.Errorf("expected T09, got %s", num) - } -} - -func TestBuildTaskContext(t *testing.T) { - tasks := []supervisor.Task{ - {ID: "T01", Title: "Init", Status: "done", Agent: "coder", Model: "Sonnet"}, - {ID: "T02", Title: "Server", Status: "active", Agent: "coder", Model: "Opus"}, - } - - ctx := buildTaskContext(tasks) - if !containsStr(ctx, "T01: Init") { - t.Error("expected T01 in context") - } - if !containsStr(ctx, "T02: Server") { - t.Error("expected T02 in context") - } - if !containsStr(ctx, "DONE") { - t.Error("expected DONE section") - } -} - -func TestSubmitPage_HasPrijavaNav(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/submit", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, `href="/submit"`) { - t.Error("expected Prijava nav link") - } -} - -func TestDashboard_HasPrijavaNav(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, `href="/submit"`) { - t.Error("expected Prijava nav link in dashboard") - } -} - -// --- T21: UI tests --- - -func TestDashboard_EscapeClosesOverlay(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "Escape") { - t.Error("expected Escape key handler for overlay") - } -} - -func TestDashboard_BackdropClickClosesOverlay(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "e.target === this") { - t.Error("expected backdrop click handler for overlay") - } -} - -func TestTaskDetail_HasInnerWrapper(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/task/T01", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "detail-inner") { - t.Error("expected detail-inner wrapper in task detail") - } -} - -func TestTaskDetail_RendersMarkdownAsHTML(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/task/T01", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - // Markdown headers should be rendered as HTML

tags - if !containsStr(body, "

") { - t.Error("expected rendered

from markdown heading") - } - // Should have docs-content class for proper styling - if !containsStr(body, "docs-content") { - t.Error("expected docs-content class on detail content") - } -} - -func TestConsolePage_ToolbarAbovePanels(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/console", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - // Toolbar should appear before console-panels in the HTML - toolbarIdx := strings.Index(body, "console-toolbar") - panelsIdx := strings.Index(body, "console-panels") - - if toolbarIdx == -1 { - t.Fatal("expected console-toolbar in console page") - } - if panelsIdx == -1 { - t.Fatal("expected console-panels in console page") - } - if toolbarIdx > panelsIdx { - t.Error("expected toolbar before panels in console HTML") - } -} - -func TestConsolePage_HasDynamicSessions(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/console", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "refreshSessions") { - t.Error("expected refreshSessions function in console page") - } - if !containsStr(body, "/console/sessions") { - t.Error("expected /console/sessions API call") - } -} - -func TestDocsPage_HasFullHeightLayout(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/docs", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "docs-container") { - t.Error("expected docs-container class") - } - if !containsStr(body, "docs-layout") { - t.Error("expected docs-layout class for grid layout") - } -} - -// --- T25: Timestamp + report tests --- - -func TestMoveTask_AddsTimestamp(t *testing.T) { - srv := setupTestServer(t) - - // Move T08 from backlog to ready - req := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=ready", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - // Read the moved file and check for timestamp - content, err := os.ReadFile(filepath.Join(srv.Config.TasksDir, "ready", "T08.md")) - if err != nil { - t.Fatalf("failed to read moved file: %v", err) - } - text := string(content) - if !containsStr(text, "## Vremena") { - t.Error("expected '## Vremena' section in task file") - } - if !containsStr(text, "Odobren (→ready)") { - t.Error("expected 'Odobren (→ready)' timestamp") - } -} - -func TestMoveTask_AppendsMultipleTimestamps(t *testing.T) { - srv := setupTestServer(t) - - // Move backlog → ready - req := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=ready", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - // Move ready → backlog (return) - req2 := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=backlog", nil) - w2 := httptest.NewRecorder() - // Need to re-fetch since task is now in ready/ - srv.Router.ServeHTTP(w2, req2) - - // Move backlog → ready again - req3 := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=ready", nil) - w3 := httptest.NewRecorder() - srv.Router.ServeHTTP(w3, req3) - - content, _ := os.ReadFile(filepath.Join(srv.Config.TasksDir, "ready", "T08.md")) - text := string(content) - - // Should have two "Odobren (→ready)" entries - count := strings.Count(text, "Odobren (→ready)") - if count != 2 { - t.Errorf("expected 2 'Odobren (→ready)' timestamps, got %d", count) - } -} - -func TestAPIMoveTask_AddsTimestamp(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=ready", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - content, _ := os.ReadFile(filepath.Join(srv.Config.TasksDir, "ready", "T08.md")) - text := string(content) - if !containsStr(text, "Odobren (→ready)") { - t.Error("expected timestamp in API-moved task file") - } -} - -func TestAppendTimestamp_CreatesTable(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "test.md") - os.WriteFile(path, []byte("# T01: Test\n\nSome content.\n"), 0644) - - err := appendTimestamp(path, "Odobren (→ready)") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - content, _ := os.ReadFile(path) - text := string(content) - if !containsStr(text, "## Vremena") { - t.Error("expected Vremena section") - } - if !containsStr(text, "| Događaj | Vreme |") { - t.Error("expected table header") - } - if !containsStr(text, "Odobren (→ready)") { - t.Error("expected timestamp row") - } -} - -func TestAppendTimestamp_AppendsToExisting(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "test.md") - os.WriteFile(path, []byte("# T01: Test\n\n## Vremena\n\n| Događaj | Vreme |\n|---------|-------|\n| Kreiran | 2026-02-20 14:00 |\n"), 0644) - - err := appendTimestamp(path, "Odobren (→ready)") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - content, _ := os.ReadFile(path) - text := string(content) - if !containsStr(text, "Kreiran") { - t.Error("expected existing row preserved") - } - if !containsStr(text, "Odobren (→ready)") { - t.Error("expected new timestamp row") - } -} - -func TestReport_RendersMarkdownInModal(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/report/T01", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - body := w.Body.String() - if !containsStr(body, "detail-inner") { - t.Error("expected detail-inner wrapper for modal") - } - if !containsStr(body, "docs-content") { - t.Error("expected docs-content class for markdown rendering") - } - if !containsStr(body, "

") { - t.Error("expected rendered markdown heading") - } - if !containsStr(body, "Izveštaj") { - t.Error("expected 'Izveštaj' in title") - } -} - -func TestReport_NoReport_ShowsTask(t *testing.T) { - srv := setupTestServer(t) - - // T08 has no report — should show task content - req := httptest.NewRequest(http.MethodGet, "/report/T08", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - body := w.Body.String() - if !containsStr(body, "detail-inner") { - t.Error("expected detail-inner wrapper") - } - if !containsStr(body, "HTTP server") { - t.Error("expected task title in fallback content") - } -} - -func TestReport_NotFoundTask(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/report/T99", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d", w.Code) - } -} - -func TestRunTask_AddsTimestamp(t *testing.T) { - srv := setupTestServer(t) - - // Move T08 to ready - os.Rename( - filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), - filepath.Join(srv.Config.TasksDir, "ready", "T08.md"), - ) - - req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - // Task should now be in active/ - content, _ := os.ReadFile(filepath.Join(srv.Config.TasksDir, "active", "T08.md")) - text := string(content) - if !containsStr(text, "Pokrenut") { - t.Error("expected 'Pokrenut' timestamp after run") - } - // Verify it's no longer in ready/ - if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "ready", "T08.md")); err == nil { - t.Error("task should no longer be in ready/") - } -} - -// --- T24: PTY tests --- - -func TestStripAnsi(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {"hello", "hello"}, - {"\x1b[32mgreen\x1b[0m", "green"}, - {"\x1b[1;31mbold red\x1b[0m", "bold red"}, - {"\x1b[?25l\x1b[?25h", ""}, - {"no \x1b[4munderline\x1b[24m here", "no underline here"}, - {"\x1b]0;title\x07text", "text"}, - } - - for _, tt := range tests { - got := stripAnsi(tt.input) - if got != tt.expected { - t.Errorf("stripAnsi(%q) = %q, want %q", tt.input, got, tt.expected) - } - } -} - -func TestReadPTY_SplitsLines(t *testing.T) { - r, w, _ := os.Pipe() - - var lines []string - done := make(chan bool) - - go func() { - readPTY(r, func(line string) { - lines = append(lines, line) - }) - done <- true - }() - - w.WriteString("line1\nline2\nline3\n") - w.Close() - <-done - - if len(lines) != 3 { - t.Fatalf("expected 3 lines, got %d: %v", len(lines), lines) - } - if lines[0] != "line1" || lines[1] != "line2" || lines[2] != "line3" { - t.Errorf("unexpected lines: %v", lines) - } -} - -func TestReadPTY_StripsAnsi(t *testing.T) { - r, w, _ := os.Pipe() - - var lines []string - done := make(chan bool) - - go func() { - readPTY(r, func(line string) { - lines = append(lines, line) - }) - done <- true - }() - - w.WriteString("\x1b[32mcolored\x1b[0m\n") - w.Close() - <-done - - if len(lines) != 1 { - t.Fatalf("expected 1 line, got %d", len(lines)) - } - if lines[0] != "colored" { - t.Errorf("expected 'colored', got %q", lines[0]) - } -} - -func TestReadPTY_HandlesPartialChunks(t *testing.T) { - r, w, _ := os.Pipe() - - var lines []string - done := make(chan bool) - - go func() { - readPTY(r, func(line string) { - lines = append(lines, line) - }) - done <- true - }() - - // Write partial, then complete - w.WriteString("partial") - w.Close() - <-done - - if len(lines) != 1 { - t.Fatalf("expected 1 line for partial, got %d: %v", len(lines), lines) - } - if lines[0] != "partial" { - t.Errorf("expected 'partial', got %q", lines[0]) - } -} - -func TestReadPTY_HandlesCarriageReturn(t *testing.T) { - r, w, _ := os.Pipe() - - var lines []string - done := make(chan bool) - - go func() { - readPTY(r, func(line string) { - lines = append(lines, line) - }) - done <- true - }() - - w.WriteString("line1\r\nline2\r\n") - w.Close() - <-done - - if len(lines) != 2 { - t.Fatalf("expected 2 lines, got %d: %v", len(lines), lines) - } - if lines[0] != "line1" || lines[1] != "line2" { - t.Errorf("unexpected lines: %v", lines) - } -} - -func containsStr(s, substr string) bool { - return len(s) >= len(substr) && findStr(s, substr) -} - -func findStr(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} - -// ── RingBuffer tests ──────────────────────────────── - -func TestRingBuffer_WriteAndRead(t *testing.T) { - rb := NewRingBuffer(16) - rb.Write([]byte("hello")) - - got := rb.Bytes() - if string(got) != "hello" { - t.Errorf("expected 'hello', got '%s'", got) - } -} - -func TestRingBuffer_Overflow(t *testing.T) { - rb := NewRingBuffer(8) - rb.Write([]byte("abcdefgh")) // exactly fills - rb.Write([]byte("ij")) // wraps around - - got := rb.Bytes() - // Should contain the last 8 bytes: "cdefghij" - if string(got) != "cdefghij" { - t.Errorf("expected 'cdefghij', got '%s'", got) - } -} - -func TestRingBuffer_Reset(t *testing.T) { - rb := NewRingBuffer(16) - rb.Write([]byte("test")) - rb.Reset() - - got := rb.Bytes() - if len(got) != 0 { - t.Errorf("expected empty after reset, got %d bytes", len(got)) - } -} - -// ── xterm.js console page tests ───────────────────── - -func TestConsolePage_HasXtermJS(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/console", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "xterm.min.js") { - t.Error("expected xterm.min.js CDN link in console page") - } - if !containsStr(body, "addon-fit") { - t.Error("expected addon-fit CDN link in console page") - } - if !containsStr(body, "xterm.css") { - t.Error("expected xterm.css CDN link in console page") - } -} - -func TestConsolePage_HasWebSocket(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/console", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "console/ws/") { - t.Error("expected WebSocket URL console/ws/ in console page") - } - if !containsStr(body, "new WebSocket") { - t.Error("expected WebSocket constructor in console page") - } -} - -func TestConsolePage_HasEmptyState(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/console", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "empty-state") { - t.Error("expected empty-state element") - } - if !containsStr(body, "Pusti") { - t.Error("expected 'Pusti' instruction in empty state") - } - if !containsStr(body, "console-terminal") { - t.Error("expected console-terminal class in JS code") - } -} - -func TestConsolePage_HasBinaryMessageSupport(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/console", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "arraybuffer") { - t.Error("expected arraybuffer binary type for WebSocket") - } - if !containsStr(body, "Uint8Array") { - t.Error("expected Uint8Array handling for binary messages") - } -} - -func TestConsolePage_HasResizeHandler(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/console", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "fitAddon.fit()") { - t.Error("expected fitAddon.fit() for resize handling") - } - if !containsStr(body, `'resize'`) { - t.Error("expected resize message type in WebSocket handler") - } -} - -// ── Task session manager tests ────────────────────── - -func TestSessionKey(t *testing.T) { - if got := sessionKey("T01", "work"); got != "T01" { - t.Errorf("expected T01, got %s", got) - } - if got := sessionKey("T01", "review"); got != "T01-review" { - t.Errorf("expected T01-review, got %s", got) - } -} - -func TestTaskSessionManager_ListEmpty(t *testing.T) { - sm := newTaskSessionManager() - sessions := sm.listSessions() - if len(sessions) != 0 { - t.Errorf("expected 0 sessions, got %d", len(sessions)) - } -} - -func TestTaskSessionManager_KillNotFound(t *testing.T) { - sm := newTaskSessionManager() - if sm.killSession("T99", "work") { - t.Error("expected false for non-existent session") - } -} - -func TestTaskSessionManager_GetNotFound(t *testing.T) { - sm := newTaskSessionManager() - if sm.getSessionByKey("T99") != nil { - t.Error("expected nil for non-existent session") - } -} - -func TestBuildWorkPrompt(t *testing.T) { - prompt := buildWorkPrompt("T08", []byte("# T08: Test task\n\nOpis.")) - - if !containsStr(prompt, "T08") { - t.Error("expected task ID in prompt") - } - if !containsStr(prompt, "Test task") { - t.Error("expected task content in prompt") - } - if !containsStr(prompt, "agents/coder/CLAUDE.md") { - t.Error("expected coder CLAUDE.md reference") - } - if !containsStr(prompt, "go test") { - t.Error("expected test instruction") - } - if !containsStr(prompt, "report") { - t.Error("expected report instruction") - } -} - -func TestBuildReviewPrompt(t *testing.T) { - prompt := buildReviewPrompt("T08", []byte("# T08: Test\n"), []byte("# Report\nSve ok.")) - - if !containsStr(prompt, "T08") { - t.Error("expected task ID in review prompt") - } - if !containsStr(prompt, "Sve ok") { - t.Error("expected report content in review prompt") - } - if !containsStr(prompt, "agents/checker/CLAUDE.md") { - t.Error("expected checker CLAUDE.md reference") - } -} - -func TestBuildReviewPrompt_NoReport(t *testing.T) { - prompt := buildReviewPrompt("T08", []byte("# T08: Test\n"), nil) - - if !containsStr(prompt, "T08") { - t.Error("expected task ID") - } - if containsStr(prompt, "Izveštaj agenta") { - t.Error("should not include report section when no report") - } -} - -func TestReviewTask_NotFound(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodPost, "/task/T99/review", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d", w.Code) - } -} - -func TestConsolePage_HasKillButton(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/console", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "killSession") { - t.Error("expected killSession function in console page") - } -} - -func TestTaskDetail_HasProveriButton(t *testing.T) { - srv := setupTestServer(t) - - // Put T08 in review - os.Rename( - filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), - filepath.Join(srv.Config.TasksDir, "review", "T08.md"), - ) - - req := httptest.NewRequest(http.MethodGet, "/task/T08", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - body := w.Body.String() - if !containsStr(body, "Proveri") { - t.Error("expected 'Proveri' button for review task") - } - if !containsStr(body, "/review") { - t.Error("expected /review endpoint in Proveri button") - } -} diff --git a/code/internal/server/sse_test.go b/code/internal/server/sse_test.go new file mode 100644 index 0000000..5d58aba --- /dev/null +++ b/code/internal/server/sse_test.go @@ -0,0 +1,121 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/dal/kaos/internal/supervisor" +) + +func TestSSE_EventsEndpoint(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/events", nil) + w := httptest.NewRecorder() + + // Use a context with cancel to stop the SSE handler + ctx, cancel := req.Context(), func() {} + _ = ctx + _ = cancel + + // Just check the handler starts without error and sets correct headers + go func() { + srv.Router.ServeHTTP(w, req) + }() + + time.Sleep(100 * time.Millisecond) + + if ct := w.Header().Get("Content-Type"); ct != "text/event-stream" { + t.Errorf("expected Content-Type text/event-stream, got %s", ct) + } +} + +func TestHashTaskState(t *testing.T) { + tasks1 := []supervisor.Task{ + {ID: "T01", Status: "done"}, + {ID: "T02", Status: "backlog"}, + } + tasks2 := []supervisor.Task{ + {ID: "T01", Status: "done"}, + {ID: "T02", Status: "ready"}, // changed + } + tasks3 := []supervisor.Task{ + {ID: "T02", Status: "backlog"}, + {ID: "T01", Status: "done"}, // same as tasks1 but different order + } + + h1 := hashTaskState(tasks1) + h2 := hashTaskState(tasks2) + h3 := hashTaskState(tasks3) + + if h1 == h2 { + t.Error("hash should differ when task status changes") + } + if h1 != h3 { + t.Error("hash should be same regardless of task order") + } +} + +func TestSSE_BroadcastOnChange(t *testing.T) { + srv := setupTestServer(t) + + // Subscribe a client + ch := srv.events.subscribe() + defer srv.events.unsubscribe(ch) + + // Trigger a check — first call sets the baseline hash and broadcasts + srv.events.checkAndBroadcast(func() string { return "board-html" }) + + // Drain initial broadcast + select { + case <-ch: + case <-time.After(100 * time.Millisecond): + } + + // Move a task to change state + os.Rename( + filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), + filepath.Join(srv.Config.TasksDir, "ready", "T08.md"), + ) + + // Trigger another check — state changed, should broadcast + srv.events.checkAndBroadcast(func() string { return "updated-board" }) + + select { + case data := <-ch: + if data != "updated-board" { + t.Errorf("expected 'updated-board', got %s", data) + } + case <-time.After(time.Second): + t.Error("expected broadcast after state change, got nothing") + } +} + +func TestSSE_NoBroadcastWithoutChange(t *testing.T) { + srv := setupTestServer(t) + + ch := srv.events.subscribe() + defer srv.events.unsubscribe(ch) + + // Two checks without changes — second should not broadcast + srv.events.checkAndBroadcast(func() string { return "board" }) + + // Drain the first broadcast (initial hash set) + select { + case <-ch: + case <-time.After(100 * time.Millisecond): + } + + srv.events.checkAndBroadcast(func() string { return "board" }) + + select { + case <-ch: + t.Error("should not broadcast when state hasn't changed") + case <-time.After(100 * time.Millisecond): + // Good — no broadcast + } +} diff --git a/code/internal/server/submit_test.go b/code/internal/server/submit_test.go new file mode 100644 index 0000000..d390f50 --- /dev/null +++ b/code/internal/server/submit_test.go @@ -0,0 +1,245 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/dal/kaos/internal/supervisor" +) + +func TestSubmitPage(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/submit", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + body := w.Body.String() + if !containsStr(body, "Klijent") { + t.Error("expected 'Klijent' mode button") + } + if !containsStr(body, "Operater") { + t.Error("expected 'Operater' mode button") + } + if !containsStr(body, "mode-client") { + t.Error("expected client mode section") + } +} + +func TestSubmitPage_ClientModeIsDefault(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/submit", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + // Operator mode should be hidden by default + if !containsStr(body, `id="mode-operator" class="submit-mode" style="display:none"`) { + t.Error("expected operator mode to be hidden by default") + } +} + +func TestSimpleSubmit_CreatesTask(t *testing.T) { + srv := setupTestServer(t) + + form := strings.NewReader("title=Test+prijava&description=Opis+testa&priority=Visok") + req := httptest.NewRequest(http.MethodPost, "/submit/simple", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + + if resp["status"] != "ok" { + t.Errorf("expected status ok, got %v", resp["status"]) + } + + taskID, ok := resp["task_id"].(string) + if !ok || taskID == "" { + t.Fatal("expected non-empty task_id") + } + + // Verify file was created in backlog + path := filepath.Join(srv.Config.TasksDir, "backlog", taskID+".md") + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("expected task file in backlog: %v", err) + } + + if !containsStr(string(content), "Test prijava") { + t.Error("expected title in task file") + } + if !containsStr(string(content), "Visok") { + t.Error("expected priority in task file") + } + if !containsStr(string(content), "klijent (prijava)") { + t.Error("expected 'klijent (prijava)' as creator") + } +} + +func TestSimpleSubmit_MissingTitle(t *testing.T) { + srv := setupTestServer(t) + + form := strings.NewReader("description=Samo+opis") + req := httptest.NewRequest(http.MethodPost, "/submit/simple", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for missing title, got %d", w.Code) + } +} + +func TestSimpleSubmit_AutoNumbering(t *testing.T) { + srv := setupTestServer(t) + + // Existing tasks: T01 (done), T08 (backlog) + // Next should be T09 + + form := strings.NewReader("title=Novi+task&priority=Srednji") + req := httptest.NewRequest(http.MethodPost, "/submit/simple", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + + taskID, _ := resp["task_id"].(string) + if taskID != "T09" { + t.Errorf("expected T09 (next after T08), got %s", taskID) + } +} + +func TestSimpleSubmit_DefaultPriority(t *testing.T) { + srv := setupTestServer(t) + + form := strings.NewReader("title=Bez+prioriteta") + req := httptest.NewRequest(http.MethodPost, "/submit/simple", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + + taskID, _ := resp["task_id"].(string) + path := filepath.Join(srv.Config.TasksDir, "backlog", taskID+".md") + content, _ := os.ReadFile(path) + + if !containsStr(string(content), "Srednji") { + t.Error("expected default priority 'Srednji'") + } +} + +func TestChatSubmit_ReturnsChatID(t *testing.T) { + srv := setupTestServer(t) + + body := `{"message":"test poruka"}` + req := httptest.NewRequest(http.MethodPost, "/submit/chat", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["chat_id"] == nil || resp["chat_id"] == "" { + t.Error("expected non-empty chat_id in response") + } +} + +func TestChatSubmit_EmptyMessage(t *testing.T) { + srv := setupTestServer(t) + + body := `{"message":""}` + req := httptest.NewRequest(http.MethodPost, "/submit/chat", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for empty message, got %d", w.Code) + } +} + +func TestChatStream_NotFound(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/submit/chat/stream/nonexistent", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestNextTaskNumber(t *testing.T) { + dir := t.TempDir() + tasksDir := filepath.Join(dir, "TASKS") + for _, f := range []string{"backlog", "ready", "active", "review", "done"} { + os.MkdirAll(filepath.Join(tasksDir, f), 0755) + } + + os.WriteFile(filepath.Join(tasksDir, "done", "T01.md"), []byte(testTask1), 0644) + os.WriteFile(filepath.Join(tasksDir, "backlog", "T08.md"), []byte(testTask2), 0644) + + num, err := nextTaskNumber(tasksDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if num != "T09" { + t.Errorf("expected T09, got %s", num) + } +} + +func TestBuildTaskContext(t *testing.T) { + tasks := []supervisor.Task{ + {ID: "T01", Title: "Init", Status: "done", Agent: "coder", Model: "Sonnet"}, + {ID: "T02", Title: "Server", Status: "active", Agent: "coder", Model: "Opus"}, + } + + ctx := buildTaskContext(tasks) + if !containsStr(ctx, "T01: Init") { + t.Error("expected T01 in context") + } + if !containsStr(ctx, "T02: Server") { + t.Error("expected T02 in context") + } + if !containsStr(ctx, "DONE") { + t.Error("expected DONE section") + } +} + +func TestSubmitPage_HasPrijavaNav(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/submit", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, `href="/submit"`) { + t.Error("expected Prijava nav link") + } +} diff --git a/code/internal/server/task_detail_test.go b/code/internal/server/task_detail_test.go new file mode 100644 index 0000000..142ae98 --- /dev/null +++ b/code/internal/server/task_detail_test.go @@ -0,0 +1,272 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/dal/kaos/internal/supervisor" +) + +func TestTaskDetailHTML(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/task/T01", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + body := w.Body.String() + if !containsStr(body, "T01") { + t.Error("expected T01 in detail HTML") + } + if !containsStr(body, "Prvi task") { + t.Error("expected task title in detail HTML") + } +} + +func TestTaskDetailHTML_NotFound(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/task/T99", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestReport_Exists(t *testing.T) { + srv := setupTestServer(t) + + // Create a report + reportsDir := filepath.Join(srv.Config.TasksDir, "reports") + os.MkdirAll(reportsDir, 0755) + os.WriteFile(filepath.Join(reportsDir, "T01-report.md"), []byte("# T01 Report\nSve ok."), 0644) + + req := httptest.NewRequest(http.MethodGet, "/report/T01", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if !containsStr(w.Body.String(), "T01 Report") { + t.Error("expected report content") + } +} + +func TestReport_NotFound(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/report/T99", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestRunTask_Ready(t *testing.T) { + srv := setupTestServer(t) + + // Move T08 to ready first + os.Rename( + filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), + filepath.Join(srv.Config.TasksDir, "ready", "T08.md"), + ) + + req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["status"] != "started" { + t.Errorf("expected status started, got %v", resp["status"]) + } + if resp["task"] != "T08" { + t.Errorf("expected task T08, got %v", resp["task"]) + } + // Verify task moved to active/ + if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "active", "T08.md")); err != nil { + t.Error("expected T08.md in active/") + } +} + +func TestRunTask_BacklogWithDeps(t *testing.T) { + srv := setupTestServer(t) + + // T08 depends on T07, T01 is in done + // T08 depends on T07 which is NOT in done → should fail + req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for unmet deps, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestRunTask_AlreadyDone(t *testing.T) { + srv := setupTestServer(t) + + // T01 is in done + req := httptest.NewRequest(http.MethodPost, "/task/T01/run", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for done task, got %d", w.Code) + } +} + +func TestRunTask_NotFound(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/task/T99/run", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestRunTask_MovesToActive(t *testing.T) { + srv := setupTestServer(t) + + // Move T08 to ready + os.Rename( + filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), + filepath.Join(srv.Config.TasksDir, "ready", "T08.md"), + ) + + req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify task moved to active/ + if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "active", "T08.md")); err != nil { + t.Error("expected T08.md in active/ after run") + } +} + +func TestResolveTaskAction_Blocked(t *testing.T) { + doneSet := map[string]bool{"T01": true} + task := supervisor.Task{ID: "T08", Status: "backlog", DependsOn: []string{"T07"}} + if got := resolveTaskAction(task, doneSet); got != "blocked" { + t.Errorf("expected blocked, got %s", got) + } +} + +func TestResolveTaskAction_Review(t *testing.T) { + doneSet := map[string]bool{"T07": true} + task := supervisor.Task{ID: "T08", Status: "backlog", DependsOn: []string{"T07"}} + if got := resolveTaskAction(task, doneSet); got != "review" { + t.Errorf("expected review, got %s", got) + } +} + +func TestResolveTaskAction_Run(t *testing.T) { + task := supervisor.Task{ID: "T08", Status: "ready"} + if got := resolveTaskAction(task, nil); got != "run" { + t.Errorf("expected run, got %s", got) + } +} + +func TestResolveTaskAction_Running(t *testing.T) { + task := supervisor.Task{ID: "T08", Status: "active"} + if got := resolveTaskAction(task, nil); got != "running" { + t.Errorf("expected running, got %s", got) + } +} + +func TestResolveTaskAction_Approve(t *testing.T) { + task := supervisor.Task{ID: "T08", Status: "review"} + if got := resolveTaskAction(task, nil); got != "approve" { + t.Errorf("expected approve, got %s", got) + } +} + +func TestResolveTaskAction_Done(t *testing.T) { + task := supervisor.Task{ID: "T01", Status: "done"} + if got := resolveTaskAction(task, nil); got != "done" { + t.Errorf("expected done, got %s", got) + } +} + +func TestReport_RendersMarkdownInModal(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/report/T01", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + body := w.Body.String() + if !containsStr(body, "detail-inner") { + t.Error("expected detail-inner wrapper for modal") + } + if !containsStr(body, "docs-content") { + t.Error("expected docs-content class for markdown rendering") + } + if !containsStr(body, "

") { + t.Error("expected rendered markdown heading") + } + if !containsStr(body, "Izveštaj") { + t.Error("expected 'Izveštaj' in title") + } +} + +func TestReport_NoReport_ShowsTask(t *testing.T) { + srv := setupTestServer(t) + + // T08 has no report — should show task content + req := httptest.NewRequest(http.MethodGet, "/report/T08", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if !containsStr(body, "detail-inner") { + t.Error("expected detail-inner wrapper") + } + if !containsStr(body, "HTTP server") { + t.Error("expected task title in fallback content") + } +} + +func TestReport_NotFoundTask(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/report/T99", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} diff --git a/code/internal/server/test_helpers_test.go b/code/internal/server/test_helpers_test.go new file mode 100644 index 0000000..5ea401f --- /dev/null +++ b/code/internal/server/test_helpers_test.go @@ -0,0 +1,82 @@ +package server + +import ( + "os" + "path/filepath" + "testing" + + "github.com/dal/kaos/internal/config" +) + +const testTask1 = `# T01: Prvi task + +**Agent:** coder +**Model:** Sonnet +**Zavisi od:** — + +--- + +## Opis + +Opis prvog taska. + +--- +` + +const testTask2 = `# T08: HTTP server + +**Agent:** coder +**Model:** Sonnet +**Zavisi od:** T07 + +--- + +## Opis + +Implementacija HTTP servera. + +--- +` + +func setupTestServer(t *testing.T) *Server { + t.Helper() + dir := t.TempDir() + + tasksDir := filepath.Join(dir, "TASKS") + for _, folder := range []string{"backlog", "ready", "active", "review", "done", "reports"} { + os.MkdirAll(filepath.Join(tasksDir, folder), 0755) + } + + os.WriteFile(filepath.Join(tasksDir, "done", "T01.md"), []byte(testTask1), 0644) + os.WriteFile(filepath.Join(tasksDir, "backlog", "T08.md"), []byte(testTask2), 0644) + + // Docs: create markdown files in project root + os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte("# CLAUDE.md\n\nGlavni fajl.\n\n| Kolona | Opis |\n|--------|------|\n| A | B |\n"), 0644) + os.WriteFile(filepath.Join(dir, "README.md"), []byte("# README\n\nOpis projekta.\n"), 0644) + os.MkdirAll(filepath.Join(dir, "agents", "coder"), 0755) + os.WriteFile(filepath.Join(dir, "agents", "coder", "CLAUDE.md"), []byte("# Coder Agent\n\nPravila kodiranja.\n"), 0644) + os.MkdirAll(filepath.Join(dir, "agents", "checker"), 0755) + os.WriteFile(filepath.Join(dir, "agents", "checker", "CLAUDE.md"), []byte("# Checker Agent\n\nBuild + Test verifikacija.\n"), 0644) + os.WriteFile(filepath.Join(tasksDir, "reports", "T01-report.md"), []byte("# T01 Report\n\n10 testova, svi prolaze.\n"), 0644) + + cfg := &config.Config{ + TasksDir: tasksDir, + ProjectPath: dir, + Port: "0", + } + + return New(cfg) +} + +func containsStr(s, substr string) bool { + return len(s) >= len(substr) && findStr(s, substr) +} + +func findStr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/code/internal/server/timestamp_test.go b/code/internal/server/timestamp_test.go new file mode 100644 index 0000000..44308f7 --- /dev/null +++ b/code/internal/server/timestamp_test.go @@ -0,0 +1,291 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMoveTask_AddsTimestamp(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=ready", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Read the moved file and check for timestamp + content, err := os.ReadFile(filepath.Join(srv.Config.TasksDir, "ready", "T08.md")) + if err != nil { + t.Fatalf("failed to read moved file: %v", err) + } + text := string(content) + if !containsStr(text, "## Vremena") { + t.Error("expected '## Vremena' section in task file") + } + if !containsStr(text, "Odobren (→ready)") { + t.Error("expected 'Odobren (→ready)' timestamp") + } +} + +func TestMoveTask_AppendsMultipleTimestamps(t *testing.T) { + srv := setupTestServer(t) + + // Move backlog → ready + req := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=ready", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + // Move ready → backlog (return) + req2 := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=backlog", nil) + w2 := httptest.NewRecorder() + // Need to re-fetch since task is now in ready/ + srv.Router.ServeHTTP(w2, req2) + + // Move backlog → ready again + req3 := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=ready", nil) + w3 := httptest.NewRecorder() + srv.Router.ServeHTTP(w3, req3) + + content, _ := os.ReadFile(filepath.Join(srv.Config.TasksDir, "ready", "T08.md")) + text := string(content) + + // Should have two "Odobren (→ready)" entries + count := strings.Count(text, "Odobren (→ready)") + if count != 2 { + t.Errorf("expected 2 'Odobren (→ready)' timestamps, got %d", count) + } +} + +func TestAppendTimestamp_CreatesTable(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.md") + os.WriteFile(path, []byte("# T01: Test\n\nSome content.\n"), 0644) + + err := appendTimestamp(path, "Odobren (→ready)") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + content, _ := os.ReadFile(path) + text := string(content) + if !containsStr(text, "## Vremena") { + t.Error("expected Vremena section") + } + if !containsStr(text, "| Događaj | Vreme |") { + t.Error("expected table header") + } + if !containsStr(text, "Odobren (→ready)") { + t.Error("expected timestamp row") + } +} + +func TestAppendTimestamp_AppendsToExisting(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.md") + os.WriteFile(path, []byte("# T01: Test\n\n## Vremena\n\n| Događaj | Vreme |\n|---------|-------|\n| Kreiran | 2026-02-20 14:00 |\n"), 0644) + + err := appendTimestamp(path, "Odobren (→ready)") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + content, _ := os.ReadFile(path) + text := string(content) + if !containsStr(text, "Kreiran") { + t.Error("expected existing row preserved") + } + if !containsStr(text, "Odobren (→ready)") { + t.Error("expected new timestamp row") + } +} + +func TestRunTask_AddsTimestamp(t *testing.T) { + srv := setupTestServer(t) + + // Move T08 to ready + os.Rename( + filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), + filepath.Join(srv.Config.TasksDir, "ready", "T08.md"), + ) + + req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Task should now be in active/ + content, _ := os.ReadFile(filepath.Join(srv.Config.TasksDir, "active", "T08.md")) + text := string(content) + if !containsStr(text, "Pokrenut") { + t.Error("expected 'Pokrenut' timestamp after run") + } + // Verify it's no longer in ready/ + if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "ready", "T08.md")); err == nil { + t.Error("task should no longer be in ready/") + } +} + +func TestStripAnsi(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"hello", "hello"}, + {"\x1b[32mgreen\x1b[0m", "green"}, + {"\x1b[1;31mbold red\x1b[0m", "bold red"}, + {"\x1b[?25l\x1b[?25h", ""}, + {"no \x1b[4munderline\x1b[24m here", "no underline here"}, + {"\x1b]0;title\x07text", "text"}, + } + + for _, tt := range tests { + got := stripAnsi(tt.input) + if got != tt.expected { + t.Errorf("stripAnsi(%q) = %q, want %q", tt.input, got, tt.expected) + } + } +} + +func TestReadPTY_SplitsLines(t *testing.T) { + r, w, _ := os.Pipe() + + var lines []string + done := make(chan bool) + + go func() { + readPTY(r, func(line string) { + lines = append(lines, line) + }) + done <- true + }() + + w.WriteString("line1\nline2\nline3\n") + w.Close() + <-done + + if len(lines) != 3 { + t.Fatalf("expected 3 lines, got %d: %v", len(lines), lines) + } + if lines[0] != "line1" || lines[1] != "line2" || lines[2] != "line3" { + t.Errorf("unexpected lines: %v", lines) + } +} + +func TestReadPTY_StripsAnsi(t *testing.T) { + r, w, _ := os.Pipe() + + var lines []string + done := make(chan bool) + + go func() { + readPTY(r, func(line string) { + lines = append(lines, line) + }) + done <- true + }() + + w.WriteString("\x1b[32mcolored\x1b[0m\n") + w.Close() + <-done + + if len(lines) != 1 { + t.Fatalf("expected 1 line, got %d", len(lines)) + } + if lines[0] != "colored" { + t.Errorf("expected 'colored', got %q", lines[0]) + } +} + +func TestReadPTY_HandlesPartialChunks(t *testing.T) { + r, w, _ := os.Pipe() + + var lines []string + done := make(chan bool) + + go func() { + readPTY(r, func(line string) { + lines = append(lines, line) + }) + done <- true + }() + + // Write partial, then complete + w.WriteString("partial") + w.Close() + <-done + + if len(lines) != 1 { + t.Fatalf("expected 1 line for partial, got %d: %v", len(lines), lines) + } + if lines[0] != "partial" { + t.Errorf("expected 'partial', got %q", lines[0]) + } +} + +func TestReadPTY_HandlesCarriageReturn(t *testing.T) { + r, w, _ := os.Pipe() + + var lines []string + done := make(chan bool) + + go func() { + readPTY(r, func(line string) { + lines = append(lines, line) + }) + done <- true + }() + + w.WriteString("line1\r\nline2\r\n") + w.Close() + <-done + + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d: %v", len(lines), lines) + } + if lines[0] != "line1" || lines[1] != "line2" { + t.Errorf("unexpected lines: %v", lines) + } +} + +func TestRingBuffer_WriteAndRead(t *testing.T) { + rb := NewRingBuffer(16) + rb.Write([]byte("hello")) + + got := rb.Bytes() + if string(got) != "hello" { + t.Errorf("expected 'hello', got '%s'", got) + } +} + +func TestRingBuffer_Overflow(t *testing.T) { + rb := NewRingBuffer(8) + rb.Write([]byte("abcdefgh")) // exactly fills + rb.Write([]byte("ij")) // wraps around + + got := rb.Bytes() + // Should contain the last 8 bytes: "cdefghij" + if string(got) != "cdefghij" { + t.Errorf("expected 'cdefghij', got '%s'", got) + } +} + +func TestRingBuffer_Reset(t *testing.T) { + rb := NewRingBuffer(16) + rb.Write([]byte("test")) + rb.Reset() + + got := rb.Bytes() + if len(got) != 0 { + t.Errorf("expected empty after reset, got %d bytes", len(got)) + } +} diff --git a/code/internal/server/ui_test.go b/code/internal/server/ui_test.go new file mode 100644 index 0000000..dc89ddb --- /dev/null +++ b/code/internal/server/ui_test.go @@ -0,0 +1,80 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestDashboard_EscapeClosesOverlay(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "Escape") { + t.Error("expected Escape key handler for overlay") + } +} + +func TestDashboard_BackdropClickClosesOverlay(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "e.target === this") { + t.Error("expected backdrop click handler for overlay") + } +} + +func TestTaskDetail_HasInnerWrapper(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/task/T01", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "detail-inner") { + t.Error("expected detail-inner wrapper in task detail") + } +} + +func TestTaskDetail_RendersMarkdownAsHTML(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/task/T01", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + // Markdown headers should be rendered as HTML

tags + if !containsStr(body, "

") { + t.Error("expected rendered

from markdown heading") + } + // Should have docs-content class for proper styling + if !containsStr(body, "docs-content") { + t.Error("expected docs-content class on detail content") + } +} + +func TestDocsPage_HasFullHeightLayout(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/docs", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "docs-container") { + t.Error("expected docs-container class") + } + if !containsStr(body, "docs-layout") { + t.Error("expected docs-layout class for grid layout") + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..3e8d242 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,159 @@ +# KAOS — Arhitektura + +**Verzija:** 0.3.0 +**Poslednje azuriranje:** 2026-02-21 + +--- + +## Tech Stack + +| Komponenta | Tehnologija | +|------------|-------------| +| Backend | Go 1.22+ | +| HTTP framework | Gin | +| Frontend | Go templates + HTMX + xterm.js | +| Terminali | xterm.js 5.5.0 (CDN) + WebSocket | +| PTY | github.com/creack/pty | +| WebSocket | github.com/gorilla/websocket | +| Markdown | github.com/yuin/goldmark | +| Drag & Drop | Sortable.js | +| Real-time | Server-Sent Events (SSE) | +| Baza | Nema (disk je source of truth) | + +--- + +## Struktura koda + +``` +code/ +├── cmd/ +│ ├── kaos-server/ # HTTP server entry point +│ └── kaos-supervisor/ # CLI supervisor (legacy) +├── internal/ +│ ├── config/ # Konfiguracija (env vars) +│ ├── server/ # HTTP handleri, PTY, WS, render +│ └── supervisor/ # Task scanner, file ops +├── web/ +│ ├── static/ # CSS, JS (htmx, sortable, theme) +│ └── templates/ # Go templates (go:embed) +│ ├── layout.html +│ ├── console.html +│ └── partials/ +│ ├── task-card.html +│ └── task-detail.html +├── go.mod +├── go.sum +└── .env.example +``` + +--- + +## Kljucne komponente + +### Server (server.go) +- Gin router sa svim rutama +- No-cache middleware za dinamicke rute +- SSE event broker za real-time update +- Task session manager za PTY sesije + +### Task Session Manager (console.go) +- `taskSessionManager` - mapa PTY sesija po task ID +- `startSession()` - spawna interaktivni claude u PTY +- Prompt se pise u temp fajl, claude dobija jednolinijsku instrukciju +- Sesije prezivljavaju page reload (PTY na serveru) + +### PTY Session (pty_session.go) +- `consolePTYSession` - wrapper oko PTY procesa +- RingBuffer (1MB) za replay output-a +- Subscriber pattern za vise WS klijenata +- `spawnTaskPTY()` - claude --permission-mode dontAsk + +### WebSocket Handler (ws.go) +- Konektuje se na postojecu PTY sesiju po kljucu +- Replay buffer na konekciju +- Bidirekcioni: PTY output -> browser, keyboard -> PTY +- Resize podrska + +### Render Engine (render.go) +- Go templates sa go:embed +- Dashboard, task detail, report modal +- Markdown -> HTML sa goldmark +- Docs sa sidebar layoutom + +### SSE Events (events.go) +- Event broker sa poll intervalom +- Hash task stanja za detekciju promena +- Broadcast samo kad se nesto promeni + +--- + +## Dijagram toka - "Pusti" dugme + +``` +Browser: klik "Pusti" + | + v +POST /task/T08/run + | + v +handleRunTask: + 1. Validacija (status, deps) + 2. MoveTask ready -> active + 3. appendTimestamp + 4. Cita task sadrzaj + 5. buildWorkPrompt -> temp fajl + 6. startSession(T08, "work", ...) + | + v + spawnTaskPTY -> claude --permission-mode dontAsk + | + v + goroutine: ceka first output, salje prompt + | + v + JSON response {status: started, task: T08} + | + v +Browser: redirect /console + | + v +refreshSessions() -> GET /console/sessions + | + v +createTerminal(T08) -> WS /console/ws/T08 + | + v +Replay buffer + live output +``` + +--- + +## Environment varijable + +| Varijabla | Opis | Primer | +|-----------|------|--------| +| KAOS_PORT | HTTP port | 8080 | +| KAOS_PROJECT_PATH | Root projekta | /root/projects/KAOS | +| KAOS_TASKS_DIR | Tasks folder | /root/projects/KAOS/TASKS | +| KAOS_TIMEOUT | Timeout za agente | 300s | + +--- + +## Testovi + +Testovi su razdvojeni po oblasti: + +| Fajl | Oblast | +|------|--------| +| test_helpers_test.go | Deljeni setup i helper funkcije | +| api_test.go | REST API endpointi | +| dashboard_test.go | Dashboard HTML rendering | +| task_detail_test.go | Task detail, report, run | +| docs_test.go | Dokumenti stranica | +| search_test.go | Pretraga | +| submit_test.go | Prijava taskova | +| sse_test.go | Server-Sent Events | +| console_test.go | Konzola, sesije, prompt builderi | +| ui_test.go | UI ponasanje | +| timestamp_test.go | Vremena, PTY, RingBuffer | +| logs_test.go | Server logovi | diff --git a/docs/SETUP.md b/docs/SETUP.md new file mode 100644 index 0000000..deb93e9 --- /dev/null +++ b/docs/SETUP.md @@ -0,0 +1,104 @@ +# KAOS — Setup + +**Poslednje azuriranje:** 2026-02-21 + +--- + +## Zahtevi + +- Go 1.22+ +- claude CLI (instaliran i dostupan u PATH) +- Linux (PTY podrska) + +--- + +## Pokretanje + +### 1. Build + +```bash +cd /root/projects/KAOS/code +go build -o kaos-server ./cmd/kaos-server/ +``` + +### 2. Environment + +```bash +export KAOS_PORT=8080 +export KAOS_PROJECT_PATH=/root/projects/KAOS +export KAOS_TASKS_DIR=/root/projects/KAOS/TASKS +export KAOS_TIMEOUT=300s +``` + +### 3. Start + +```bash +nohup ./kaos-server > /tmp/kaos-server.log 2>&1 & +``` + +### 4. Provera + +```bash +curl http://localhost:8080/api/tasks +``` + +--- + +## Testovi + +```bash +cd /root/projects/KAOS/code + +# Svi testovi +go test ./... -count=1 + +# Samo server testovi +go test ./internal/server/ -count=1 -v + +# Build + vet +go build ./... +go vet ./... +``` + +--- + +## Task folderi + +``` +TASKS/ +├── backlog/ # Novi taskovi +├── ready/ # Odobreni za rad +├── active/ # U izradi +├── review/ # Ceka pregled +├── done/ # Zavrseno +└── reports/ # Izvestaji +``` + +Svaki task je markdown fajl (npr. `T08.md`). + +--- + +## Struktura task fajla + +```markdown +# T08: Naziv taska + +**Kreirao:** planer +**Datum:** 2026-02-20 +**Agent:** coder +**Model:** Sonnet +**Zavisi od:** T07 + +--- + +## Opis + +Sta treba da se uradi. + +## Vremena + +| Dogadjaj | Vreme | +|---------|-------| +| Odobren (->ready) | 2026-02-20 14:00 | +| Pokrenut (->active) | 2026-02-20 14:05 | +``` diff --git a/docs/SPEC.md b/docs/SPEC.md new file mode 100644 index 0000000..951482e --- /dev/null +++ b/docs/SPEC.md @@ -0,0 +1,106 @@ +# KAOS — Specifikacija + +**Verzija:** 0.3.0 +**Poslednje azuriranje:** 2026-02-21 + +--- + +## Pregled + +KAOS je sistem za razvoj softvera sa AI agentima pod ljudskim nadzorom. +Web dashboard omogucava operateru da upravlja taskovima, pokrece claude agente, +i prati rad u realnom vremenu kroz konzolne terminale. + +--- + +## Funkcionalnosti + +### Kanban Board +- 5 kolona: Backlog, Ready, Active, Review, Done +- Drag & drop premestanje taskova (Sortable.js) +- SSE auto-refresh kad se stanje promeni +- Dugmad po statusu: Odobri, Pusti, Proveri, Izvestaj +- Task detail modal sa markdown renderovanjem + +### Konzola (Task PTY Sessions) +- Svaki task dobija sopstvenu claude CLI sesiju u PTY +- "Pusti" dugme: premesta task u active, pokrece interaktivni claude +- "Proveri" dugme: pokrece review claude sesiju za task u review/ +- Terminali u browseru via xterm.js + WebSocket +- Replay buffer (1MB ring buffer) za reconnect +- Sesije prezivljavaju page reload +- Dinamicko dodavanje/brisanje terminala po aktivnim sesijama +- Kill dugme za zavrsavanje sesije + +### Pretraga +- Pretrazuje taskove, dokumente i izvestaje +- Case-insensitive, sa snippet kontekstom +- Real-time rezultati (htmx delay:300ms) + +### Dokumenti +- Pregled svih .md fajlova u projektu +- Sidebar + main layout +- Markdown renderovanje sa tabelama +- Breadcrumbs navigacija +- HTMX fragmenti za client-side navigaciju + +### Prijava taskova +- Klijent mod: jednostavna forma (naslov + opis + prioritet) +- Operater mod: chat sa claude CLI za kreiranje taska + +### Server logovi +- GET /api/logs/tail - poslednjih 100 linija loga +- Modal prikaz na dashboardu + +### Vremena (Timestamps) +- Svaki prelaz taska belexi timestamp u Vremena tabelu +- Automatski u task .md fajl + +--- + +## Task workflow + +``` +backlog --> ready --> active --> review --> done + | | | + <---------- | + <------------------- +``` + +| Prelaz | Ko | Kako | +|--------|----|------| +| backlog -> ready | operater | Odobri dugme | +| ready -> active | server | Pusti dugme (automatski) | +| active -> review | claude agent | Kad zavrsi rad | +| review -> done | operater/checker | Odobri ili Proveri | +| review -> active | operater/checker | Vrati na doradu | +| ready -> backlog | operater | Vrati dugme | + +--- + +## API endpointi + +| Metod | Putanja | Opis | +|-------|---------|------| +| GET | / | Dashboard (Kanban) | +| GET | /api/tasks | Svi taskovi (JSON) | +| GET | /api/task/:id | Detalj taska (JSON) | +| POST | /api/task/:id/move?to= | Premesti task | +| GET | /task/:id | Task detail (HTML) | +| POST | /task/:id/move?to= | Premesti + vrati board | +| POST | /task/:id/run | Pusti task (active + claude) | +| POST | /task/:id/review | Pokreni review sesiju | +| GET | /report/:id | Izvestaj/task modal | +| GET | /events | SSE stream | +| GET | /search?q= | Pretraga | +| GET | /console | Konzola stranica | +| GET | /console/sessions | Aktivne sesije (JSON) | +| POST | /console/kill/:taskID | Ubij sesiju | +| GET | /console/ws/:key | WebSocket terminal | +| GET | /api/logs/tail | Server logovi | +| GET | /docs | Lista dokumenata | +| GET | /docs/*path | Pregled dokumenta | +| GET | /submit | Prijava stranica | +| POST | /submit/simple | Prijava forme | +| POST | /submit/chat | Operater chat | +| GET | /submit/chat/stream/:id | Chat SSE stream |