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 |
| |