Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e81eade2e1 | ||
|
|
098ed13705 |
68
README.md
68
README.md
@ -1,9 +1,9 @@
|
|||||||
# KAOS — AI-Supervised Development System
|
# KAOS — AI-Supervised Development System
|
||||||
|
|
||||||
**Verzija:** 0.1.0
|
**Verzija:** 0.3.0
|
||||||
**Status:** Pokretanje
|
**Status:** Aktivan razvoj
|
||||||
**Autor:** DAL d.o.o.
|
**Autor:** DAL d.o.o.
|
||||||
**Poslednje ažuriranje:** 2026-02-20
|
**Poslednje azuriranje:** 2026-02-21
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -101,13 +101,23 @@ Deploy ili dorada
|
|||||||
│ ├── regulations/
|
│ ├── regulations/
|
||||||
│ └── third-party/
|
│ └── third-party/
|
||||||
│
|
│
|
||||||
└── TASKS/ ← taskovi, specifikacije, izveštaji
|
├── docs/ ← dokumentacija
|
||||||
├── MASTER-STATUS.md
|
│ ├── SPEC.md
|
||||||
├── Architecture.md
|
│ ├── ARCHITECTURE.md
|
||||||
├── Workflow-Spec.md
|
│ └── SETUP.md
|
||||||
├── Supervisor-Spec.md
|
│
|
||||||
├── Multi-Agent-Spec.md
|
├── code/ ← Go kod (server, testovi)
|
||||||
├── Implementation-Tasks.md
|
│ ├── cmd/kaos-server/
|
||||||
|
│ ├── internal/server/
|
||||||
|
│ ├── internal/supervisor/
|
||||||
|
│ └── web/templates/
|
||||||
|
│
|
||||||
|
└── TASKS/ ← taskovi po stanju
|
||||||
|
├── backlog/
|
||||||
|
├── ready/
|
||||||
|
├── active/
|
||||||
|
├── review/
|
||||||
|
├── done/
|
||||||
└── reports/
|
└── reports/
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -115,30 +125,26 @@ Deploy ili dorada
|
|||||||
|
|
||||||
## Verzije
|
## Verzije
|
||||||
|
|
||||||
### v0.1 — Osnova (TRENUTNO)
|
### v0.1 — Osnova
|
||||||
- Mastermind + agenti definisani u CLAUDE.md fajlovima
|
- Mastermind + agenti definisani u CLAUDE.md fajlovima
|
||||||
- Supervisor: ručno pokretanje (`kaos-supervisor run T01`)
|
- Supervisor: rucno pokretanje (`kaos-supervisor run T01`)
|
||||||
- Checker: build + test + vet (deterministički)
|
- Checker: build + test + vet
|
||||||
- Izveštaji: markdown u TASKS/reports/
|
- Izvestaji: markdown u TASKS/reports/
|
||||||
- Git: direktno na main
|
|
||||||
- Nema baze, nema frontend-a, nema AI trijaže
|
|
||||||
|
|
||||||
### v0.2 — Automatizacija (planirano)
|
### v0.2 — Dashboard
|
||||||
- Supervisor daemon ili watch folder
|
- Web dashboard sa Kanban board-om
|
||||||
- AI trijaža prijava
|
- Drag & drop premestanje taskova
|
||||||
- AI compliance provere (modul, pravila, konvencije)
|
- SSE real-time update
|
||||||
- Staging → main branch strategija
|
- Pretraga, dokumenti, prijava taskova
|
||||||
- Auto-retry za flaky testove
|
|
||||||
- Notifikacije (konfigurabilan kanal)
|
|
||||||
|
|
||||||
### v0.3 — Kompletni ekosistem (planirano)
|
### v0.3 — Konzola i PTY (TRENUTNO)
|
||||||
- Frontend dashboard
|
- xterm.js terminali u browseru
|
||||||
- WebSocket real-time praćenje
|
- Svaki task dobija sopstvenu claude PTY sesiju
|
||||||
- Help sistem
|
- "Pusti" automatski pokrece rad
|
||||||
- Embed SDK
|
- "Proveri" pokrece review sesiju
|
||||||
- Cost tracking dashboard
|
- WebSocket za real-time terminal I/O
|
||||||
- Metrike i analitika
|
- Replay buffer za reconnect
|
||||||
- Distribucija prema licencama
|
- 125+ testova u 12 fajlova
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
66
TESTING.md
Normal file
66
TESTING.md
Normal file
@ -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
|
||||||
@ -2,3 +2,4 @@ KAOS_TIMEOUT=30m
|
|||||||
KAOS_PROJECT_PATH=.
|
KAOS_PROJECT_PATH=.
|
||||||
KAOS_TASKS_DIR=../TASKS
|
KAOS_TASKS_DIR=../TASKS
|
||||||
KAOS_PORT=8080
|
KAOS_PORT=8080
|
||||||
|
KAOS_LOG_FILE=/tmp/kaos-server.log
|
||||||
|
|||||||
@ -20,6 +20,8 @@ type Config struct {
|
|||||||
TasksDir string
|
TasksDir string
|
||||||
// Port is the HTTP server port.
|
// Port is the HTTP server port.
|
||||||
Port string
|
Port string
|
||||||
|
// LogFile is the path to the server log file (optional).
|
||||||
|
LogFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables.
|
// Load reads configuration from environment variables.
|
||||||
@ -53,11 +55,14 @@ func Load() (*Config, error) {
|
|||||||
port = "8080"
|
port = "8080"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logFile := os.Getenv("KAOS_LOG_FILE")
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
ProjectPath: projectPath,
|
ProjectPath: projectPath,
|
||||||
TasksDir: tasksDir,
|
TasksDir: tasksDir,
|
||||||
Port: port,
|
Port: port,
|
||||||
|
LogFile: logFile,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
205
code/internal/server/api_test.go
Normal file
205
code/internal/server/api_test.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -77,7 +77,13 @@ func (sm *taskSessionManager) startSession(taskID, sessionType, projectDir, prom
|
|||||||
|
|
||||||
log.Printf("Session[%s]: started (PID %d)", key, ptySess.Cmd.Process.Pid)
|
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() {
|
go func() {
|
||||||
subID := fmt.Sprintf("init-%d", time.Now().UnixNano())
|
subID := fmt.Sprintf("init-%d", time.Now().UnixNano())
|
||||||
ch := ptySess.Subscribe(subID)
|
ch := ptySess.Subscribe(subID)
|
||||||
@ -99,9 +105,10 @@ func (sm *taskSessionManager) startSession(taskID, sessionType, projectDir, prom
|
|||||||
// Let claude fully render its welcome screen
|
// Let claude fully render its welcome screen
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
// Type the prompt
|
// Send one-liner that tells claude to read the prompt file
|
||||||
log.Printf("Session[%s]: sending prompt (%d bytes)", key, len(prompt))
|
oneliner := fmt.Sprintf("Pročitaj fajl %s i uradi SVE što piše unutra.", promptFile)
|
||||||
ptySess.WriteInput([]byte(prompt + "\n"))
|
log.Printf("Session[%s]: sending prompt via file %s (%d bytes)", key, promptFile, len(prompt))
|
||||||
|
ptySess.WriteInput([]byte(oneliner + "\n"))
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return sess, nil
|
return sess, nil
|
||||||
|
|||||||
312
code/internal/server/console_test.go
Normal file
312
code/internal/server/console_test.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
317
code/internal/server/dashboard_test.go
Normal file
317
code/internal/server/dashboard_test.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
201
code/internal/server/docs_test.go
Normal file
201
code/internal/server/docs_test.go
Normal file
@ -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, "<table>") || !containsStr(body, "<th>") {
|
||||||
|
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, "<!DOCTYPE html>") {
|
||||||
|
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 := `<a href="README.md">link</a> and <a href="https://example.com">ext</a>`
|
||||||
|
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 := `<a href="CLAUDE.md">link</a>`
|
||||||
|
result := rewriteLinksSimple(input, "agents/coder")
|
||||||
|
if !containsStr(result, `/docs/agents/coder/CLAUDE.md`) {
|
||||||
|
t.Errorf("expected nested rewritten link, got: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
code/internal/server/logs.go
Normal file
41
code/internal/server/logs.go
Normal file
@ -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:]
|
||||||
|
}
|
||||||
117
code/internal/server/search_test.go
Normal file
117
code/internal/server/search_test.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
121
code/internal/server/sse_test.go
Normal file
121
code/internal/server/sse_test.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
245
code/internal/server/submit_test.go
Normal file
245
code/internal/server/submit_test.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
272
code/internal/server/task_detail_test.go
Normal file
272
code/internal/server/task_detail_test.go
Normal file
@ -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, "<h1>") {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
82
code/internal/server/test_helpers_test.go
Normal file
82
code/internal/server/test_helpers_test.go
Normal file
@ -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
|
||||||
|
}
|
||||||
425
code/internal/server/timestamp_test.go
Normal file
425
code/internal/server/timestamp_test.go
Normal file
@ -0,0 +1,425 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"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 TestAppendTimestamp_Format(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "test.md")
|
||||||
|
os.WriteFile(path, []byte("# T01: Test\n"), 0644)
|
||||||
|
|
||||||
|
appendTimestamp(path, "Pokrenut (→active)")
|
||||||
|
|
||||||
|
content, _ := os.ReadFile(path)
|
||||||
|
text := string(content)
|
||||||
|
|
||||||
|
// Verify timestamp format YYYY-MM-DD HH:MM
|
||||||
|
re := regexp.MustCompile(`\| Pokrenut \(→active\) \| \d{4}-\d{2}-\d{2} \d{2}:\d{2} \|`)
|
||||||
|
if !re.MatchString(text) {
|
||||||
|
t.Errorf("expected timestamp format YYYY-MM-DD HH:MM, got:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendTimestamp_FileNotFound(t *testing.T) {
|
||||||
|
err := appendTimestamp("/nonexistent/path/task.md", "test")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for nonexistent file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveEventLabel_AllFolders(t *testing.T) {
|
||||||
|
expected := map[string]string{
|
||||||
|
"ready": "Odobren (→ready)",
|
||||||
|
"active": "Pokrenut (→active)",
|
||||||
|
"review": "Završen (→review)",
|
||||||
|
"done": "Odobren (→done)",
|
||||||
|
}
|
||||||
|
|
||||||
|
for folder, label := range expected {
|
||||||
|
got, ok := moveEventLabel[folder]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("missing label for folder %s", folder)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != label {
|
||||||
|
t.Errorf("folder %s: expected %q, got %q", folder, label, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoneTimestamp_ReviewToDone(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, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
content, _ := os.ReadFile(filepath.Join(srv.Config.TasksDir, "done", "T08.md"))
|
||||||
|
text := string(content)
|
||||||
|
|
||||||
|
if !containsStr(text, "Odobren (→done)") {
|
||||||
|
t.Error("expected 'Odobren (→done)' timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskDetail_DoneShowsReportButton(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// T01 is in done and has a report
|
||||||
|
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, "/report/T01") {
|
||||||
|
t.Error("expected report link for done task")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskDetail_DoneWithoutReportShowsButton(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// Create a done task without a report
|
||||||
|
os.WriteFile(
|
||||||
|
filepath.Join(srv.Config.TasksDir, "done", "T02.md"),
|
||||||
|
[]byte("# T02: Bez reporta\n\n**Agent:** coder\n**Model:** Sonnet\n**Zavisi od:** —\n\n---\n\n## Opis\n\nTest task bez reporta.\n"),
|
||||||
|
0644,
|
||||||
|
)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/task/T02", 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, "/report/T02") {
|
||||||
|
t.Error("expected report button for done task even without report file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimestampVisibleInTaskDetail(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// Add timestamps to T01 (done task)
|
||||||
|
taskPath := filepath.Join(srv.Config.TasksDir, "done", "T01.md")
|
||||||
|
appendTimestamp(taskPath, "Kreiran")
|
||||||
|
appendTimestamp(taskPath, "Pokrenut (→active)")
|
||||||
|
|
||||||
|
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, "Vremena") {
|
||||||
|
t.Error("expected Vremena section visible in task detail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
80
code/internal/server/ui_test.go
Normal file
80
code/internal/server/ui_test.go
Normal file
@ -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 <h1> tags
|
||||||
|
if !containsStr(body, "<h1>") {
|
||||||
|
t.Error("expected rendered <h1> 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,7 +18,7 @@
|
|||||||
{{else if eq .Action "approve"}}
|
{{else if eq .Action "approve"}}
|
||||||
<button class="btn btn-approve" hx-get="/task/{{.ID}}" hx-target="#task-detail" hx-swap="innerHTML" onclick="event.stopPropagation()">Pregledaj</button>
|
<button class="btn btn-approve" hx-get="/task/{{.ID}}" hx-target="#task-detail" hx-swap="innerHTML" onclick="event.stopPropagation()">Pregledaj</button>
|
||||||
{{else if eq .Action "done"}}
|
{{else if eq .Action "done"}}
|
||||||
<button class="btn btn-report" hx-get="/report/{{.ID}}" hx-target="#task-detail" hx-swap="innerHTML" onclick="event.stopPropagation()">Izvestaj</button>
|
<button class="btn btn-report" hx-get="/report/{{.ID}}" hx-target="#task-detail" hx-swap="innerHTML" onclick="event.stopPropagation()">📊 Izveštaj</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,9 +8,9 @@
|
|||||||
<p><strong>Zavisi od:</strong> {{joinDeps .Task.DependsOn}}</p>
|
<p><strong>Zavisi od:</strong> {{joinDeps .Task.DependsOn}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{if .HasReport}}
|
{{if or .HasReport (eq .Task.Status "done")}}
|
||||||
<div class="detail-actions">
|
<div class="detail-actions">
|
||||||
<button class="btn btn-report" hx-get="/report/{{.Task.ID}}" hx-target="#task-detail" hx-swap="innerHTML">Izvestaj</button>
|
<button class="btn btn-report" hx-get="/report/{{.Task.ID}}" hx-target="#task-detail" hx-swap="innerHTML">📊 Izveštaj</button>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div class="detail-actions">
|
<div class="detail-actions">
|
||||||
|
|||||||
159
docs/ARCHITECTURE.md
Normal file
159
docs/ARCHITECTURE.md
Normal file
@ -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 |
|
||||||
104
docs/SETUP.md
Normal file
104
docs/SETUP.md
Normal file
@ -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 |
|
||||||
|
```
|
||||||
106
docs/SPEC.md
Normal file
106
docs/SPEC.md
Normal file
@ -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 |
|
||||||
Loading…
Reference in New Issue
Block a user