KAOS/code/internal/server/server_test.go
djuka 003650df24 T24: PTY za konzolu i operater chat — real-time streaming
Konzola i operater chat sada koriste pseudo-terminal (PTY) umesto
pipe-a. Claude CLI detektuje terminal i šalje output odmah umesto
da bufferuje. ANSI escape sekvence se uklanjaju pre slanja kroz SSE.

Novi fajl: pty.go (startPTY, readPTY, stripAnsi)
Biblioteka: github.com/creack/pty v1.1.24
5 novih testova za PTY funkcionalnost.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:13:13 +00:00

1814 lines
46 KiB
Go

package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/dal/kaos/internal/config"
"github.com/dal/kaos/internal/supervisor"
)
const testTask1 = `# T01: Prvi task
**Agent:** coder
**Model:** Sonnet
**Zavisi od:** —
---
## Opis
Opis prvog taska.
---
`
const testTask2 = `# T08: HTTP server
**Agent:** coder
**Model:** Sonnet
**Zavisi od:** T07
---
## Opis
Implementacija HTTP servera.
---
`
func setupTestServer(t *testing.T) *Server {
t.Helper()
dir := t.TempDir()
tasksDir := filepath.Join(dir, "TASKS")
for _, folder := range []string{"backlog", "ready", "active", "review", "done", "reports"} {
os.MkdirAll(filepath.Join(tasksDir, folder), 0755)
}
os.WriteFile(filepath.Join(tasksDir, "done", "T01.md"), []byte(testTask1), 0644)
os.WriteFile(filepath.Join(tasksDir, "backlog", "T08.md"), []byte(testTask2), 0644)
// Docs: create markdown files in project root
os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte("# CLAUDE.md\n\nGlavni fajl.\n\n| Kolona | Opis |\n|--------|------|\n| A | B |\n"), 0644)
os.WriteFile(filepath.Join(dir, "README.md"), []byte("# README\n\nOpis projekta.\n"), 0644)
os.MkdirAll(filepath.Join(dir, "agents", "coder"), 0755)
os.WriteFile(filepath.Join(dir, "agents", "coder", "CLAUDE.md"), []byte("# Coder Agent\n\nPravila kodiranja.\n"), 0644)
os.MkdirAll(filepath.Join(dir, "agents", "checker"), 0755)
os.WriteFile(filepath.Join(dir, "agents", "checker", "CLAUDE.md"), []byte("# Checker Agent\n\nBuild + Test verifikacija.\n"), 0644)
os.WriteFile(filepath.Join(tasksDir, "reports", "T01-report.md"), []byte("# T01 Report\n\n10 testova, svi prolaze.\n"), 0644)
cfg := &config.Config{
TasksDir: tasksDir,
ProjectPath: dir,
Port: "0",
}
return New(cfg)
}
func TestAPIGetTasks(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var tasks []taskResponse
if err := json.Unmarshal(w.Body.Bytes(), &tasks); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(tasks) != 2 {
t.Fatalf("expected 2 tasks, got %d", len(tasks))
}
// Check that tasks have correct statuses
statuses := map[string]string{}
for _, task := range tasks {
statuses[task.ID] = task.Status
}
if statuses["T01"] != "done" {
t.Errorf("expected T01 status done, got %s", statuses["T01"])
}
if statuses["T08"] != "backlog" {
t.Errorf("expected T08 status backlog, got %s", statuses["T08"])
}
}
func TestAPIGetTask(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/api/task/T01", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var detail taskDetailResponse
if err := json.Unmarshal(w.Body.Bytes(), &detail); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if detail.ID != "T01" {
t.Errorf("expected T01, got %s", detail.ID)
}
if detail.Content == "" {
t.Error("expected non-empty content")
}
}
func TestAPIGetTask_NotFound(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/api/task/T99", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestAPIMoveTask(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=ready", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// Verify file was moved
if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "ready", "T08.md")); err != nil {
t.Error("expected T08.md in ready/")
}
if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "backlog", "T08.md")); !os.IsNotExist(err) {
t.Error("expected T08.md removed from backlog/")
}
}
func TestAPIMoveTask_NotFound(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodPost, "/api/task/T99/move?to=ready", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestAPIMoveTask_InvalidFolder(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=invalid", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestDashboardHTML(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if !containsStr(body, "KAOS Dashboard") {
t.Error("expected 'KAOS Dashboard' in HTML")
}
if !containsStr(body, "T01") {
t.Error("expected T01 in HTML")
}
if !containsStr(body, "T08") {
t.Error("expected T08 in HTML")
}
if !containsStr(body, "BACKLOG") {
t.Error("expected BACKLOG column in HTML")
}
if !containsStr(body, "DONE") {
t.Error("expected DONE column in HTML")
}
}
func TestTaskDetailHTML(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/task/T01", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if !containsStr(body, "T01") {
t.Error("expected T01 in detail HTML")
}
if !containsStr(body, "Prvi task") {
t.Error("expected task title in detail HTML")
}
}
func TestTaskDetailHTML_NotFound(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/task/T99", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestHTMLMoveTask(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=ready", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// Should return updated dashboard HTML
body := w.Body.String()
if !containsStr(body, "KAOS Dashboard") {
t.Error("expected dashboard HTML after move")
}
}
func TestDashboardHTML_HasAllColumns(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
for _, col := range []string{"BACKLOG", "READY", "ACTIVE", "REVIEW", "DONE"} {
if !containsStr(body, col) {
t.Errorf("expected %s column in dashboard", col)
}
}
}
func TestDashboardHTML_HasHTMXAttributes(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "hx-get") {
t.Error("expected hx-get attributes in HTML")
}
if !containsStr(body, "hx-target") {
t.Error("expected hx-target attributes in HTML")
}
}
func TestDashboardHTML_TasksInCorrectColumns(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
// T01 should be in done column, T08 in backlog
if !containsStr(body, `id="col-done"`) {
t.Error("expected col-done in HTML")
}
if !containsStr(body, `id="col-backlog"`) {
t.Error("expected col-backlog in HTML")
}
}
func TestReport_Exists(t *testing.T) {
srv := setupTestServer(t)
// Create a report
reportsDir := filepath.Join(srv.Config.TasksDir, "reports")
os.MkdirAll(reportsDir, 0755)
os.WriteFile(filepath.Join(reportsDir, "T01-report.md"), []byte("# T01 Report\nSve ok."), 0644)
req := httptest.NewRequest(http.MethodGet, "/report/T01", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if !containsStr(w.Body.String(), "T01 Report") {
t.Error("expected report content")
}
}
func TestReport_NotFound(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/report/T99", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestTaskDetail_HasMoveButtons(t *testing.T) {
srv := setupTestServer(t)
// T08 is in backlog, should have "Odobri" button
req := httptest.NewRequest(http.MethodGet, "/task/T08", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if !containsStr(body, "Odobri") {
t.Error("expected 'Odobri' button for backlog task")
}
}
func TestAPIMoveTask_ForbiddenToActive(t *testing.T) {
srv := setupTestServer(t)
// Put T08 in ready first
os.Rename(
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
filepath.Join(srv.Config.TasksDir, "ready", "T08.md"),
)
// Try to move ready → active (agent-only)
req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=active", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for ready→active, got %d: %s", w.Code, w.Body.String())
}
}
func TestAPIMoveTask_ForbiddenActiveToReview(t *testing.T) {
srv := setupTestServer(t)
// Put T08 in active
os.Rename(
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
filepath.Join(srv.Config.TasksDir, "active", "T08.md"),
)
// Try to move active → review (agent-only)
req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=review", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for active→review, got %d: %s", w.Code, w.Body.String())
}
}
func TestAPIMoveTask_AllowedBacklogToReady(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=ready", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for backlog→ready, got %d: %s", w.Code, w.Body.String())
}
}
func TestAPIMoveTask_AllowedReviewToDone(t *testing.T) {
srv := setupTestServer(t)
// Put T08 in review
os.Rename(
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
filepath.Join(srv.Config.TasksDir, "review", "T08.md"),
)
req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=done", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for review→done, got %d: %s", w.Code, w.Body.String())
}
}
func TestDashboardHTML_HasSortableScript(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "sortable.min.js") {
t.Error("expected sortable.min.js script tag")
}
if !containsStr(body, "initSortable") {
t.Error("expected initSortable function")
}
}
func TestDashboardHTML_HasDataFolderAttributes(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, `data-folder="backlog"`) {
t.Error("expected data-folder attribute on column-tasks")
}
if !containsStr(body, `data-folder="ready"`) {
t.Error("expected data-folder=ready attribute")
}
}
func TestIsMoveAllowed(t *testing.T) {
tests := []struct {
from, to string
allowed bool
}{
{"backlog", "ready", true},
{"ready", "backlog", true},
{"review", "done", true},
{"review", "ready", true},
{"done", "review", true},
{"ready", "active", false},
{"active", "review", false},
{"backlog", "done", false},
{"backlog", "active", false},
{"done", "backlog", false},
{"ready", "ready", false},
}
for _, tt := range tests {
got := isMoveAllowed(tt.from, tt.to)
if got != tt.allowed {
t.Errorf("isMoveAllowed(%s, %s) = %v, want %v", tt.from, tt.to, got, tt.allowed)
}
}
}
func TestNoCacheHeaders(t *testing.T) {
srv := setupTestServer(t)
// Dynamic route should have no-cache headers
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
cc := w.Header().Get("Cache-Control")
if !containsStr(cc, "no-store") {
t.Errorf("expected Cache-Control no-store on dashboard, got %q", cc)
}
// API route should also have no-cache headers
req2 := httptest.NewRequest(http.MethodGet, "/api/tasks", nil)
w2 := httptest.NewRecorder()
srv.Router.ServeHTTP(w2, req2)
cc2 := w2.Header().Get("Cache-Control")
if !containsStr(cc2, "no-store") {
t.Errorf("expected Cache-Control no-store on API, got %q", cc2)
}
}
func TestDashboardReflectsDiskChanges(t *testing.T) {
srv := setupTestServer(t)
// Initial state: T08 in backlog
req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
var tasks1 []taskResponse
json.Unmarshal(w.Body.Bytes(), &tasks1)
found := false
for _, task := range tasks1 {
if task.ID == "T08" && task.Status == "backlog" {
found = true
}
}
if !found {
t.Fatal("expected T08 in backlog initially")
}
// Move file on disk (simulating external change)
os.Rename(
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
filepath.Join(srv.Config.TasksDir, "ready", "T08.md"),
)
// Second request should reflect the change without server restart
req2 := httptest.NewRequest(http.MethodGet, "/api/tasks", nil)
w2 := httptest.NewRecorder()
srv.Router.ServeHTTP(w2, req2)
var tasks2 []taskResponse
json.Unmarshal(w2.Body.Bytes(), &tasks2)
found = false
for _, task := range tasks2 {
if task.ID == "T08" && task.Status == "ready" {
found = true
}
}
if !found {
t.Fatal("expected T08 in ready after disk move — server did not read fresh state")
}
}
func TestDocsList(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/docs", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !containsStr(body, "CLAUDE.md") {
t.Error("expected CLAUDE.md in docs list")
}
if !containsStr(body, "README.md") {
t.Error("expected README.md in docs list")
}
if !containsStr(body, "agents/coder/CLAUDE.md") {
t.Error("expected agents/coder/CLAUDE.md in docs list")
}
}
func TestDocsView_CLAUDE(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/docs/CLAUDE.md", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !containsStr(body, "Glavni fajl") {
t.Error("expected rendered markdown content")
}
// Should have table rendered as HTML
if !containsStr(body, "<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 TestSearch_FindsTask(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/search?q=Prvi", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if !containsStr(body, "T01") {
t.Error("expected T01 in search results for 'Prvi'")
}
}
func TestSearch_FindsTaskByID(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/search?q=T08", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "T08") {
t.Error("expected T08 in search results")
}
}
func TestSearch_FindsDocument(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/search?q=checker", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "checker/CLAUDE.md") {
t.Error("expected checker CLAUDE.md in search results")
}
}
func TestSearch_FindsReport(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/search?q=prolaze", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "T01-report.md") {
t.Error("expected T01 report in search results")
}
}
func TestSearch_EmptyQuery(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/search?q=", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if w.Body.Len() != 0 {
t.Errorf("expected empty response for empty query, got %d bytes", w.Body.Len())
}
}
func TestSearch_NoResults(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/search?q=xyznepostoji", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "Nema rezultata") {
t.Error("expected 'Nema rezultata' message")
}
}
func TestSearch_CaseInsensitive(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/search?q=CODER", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "coder") {
t.Error("expected case-insensitive match for 'CODER'")
}
}
func TestSearch_HasSnippet(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/search?q=kodiranja", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "kodiranja") {
t.Error("expected snippet with 'kodiranja' text")
}
}
func TestRunTask_Ready(t *testing.T) {
srv := setupTestServer(t)
// Move T08 to ready first
os.Rename(
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
filepath.Join(srv.Config.TasksDir, "ready", "T08.md"),
)
req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["status"] != "started" {
t.Errorf("expected status started, got %v", resp["status"])
}
if resp["session"] == nil {
t.Error("expected session number in response")
}
}
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_BothSessionsBusy(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"),
)
// Occupy both sessions
srv.console.sessions[0].mu.Lock()
srv.console.sessions[0].status = "running"
srv.console.sessions[0].mu.Unlock()
srv.console.sessions[1].mu.Lock()
srv.console.sessions[1].status = "running"
srv.console.sessions[1].mu.Unlock()
req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409 when both sessions busy, got %d: %s", w.Code, w.Body.String())
}
// Clean up
srv.console.sessions[0].mu.Lock()
srv.console.sessions[0].status = "idle"
srv.console.sessions[0].mu.Unlock()
srv.console.sessions[1].mu.Lock()
srv.console.sessions[1].status = "idle"
srv.console.sessions[1].mu.Unlock()
}
func TestDashboardHTML_HasRunButton(t *testing.T) {
srv := setupTestServer(t)
// Move T08 to ready to test run button
os.Rename(
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
filepath.Join(srv.Config.TasksDir, "ready", "T08.md"),
)
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "Pusti") {
t.Error("expected 'Pusti' button for ready task")
}
if !containsStr(body, "btn-run") {
t.Error("expected btn-run class")
}
}
func TestResolveTaskAction_Blocked(t *testing.T) {
doneSet := map[string]bool{"T01": true}
task := supervisor.Task{ID: "T08", Status: "backlog", DependsOn: []string{"T07"}}
if got := resolveTaskAction(task, doneSet); got != "blocked" {
t.Errorf("expected blocked, got %s", got)
}
}
func TestResolveTaskAction_Review(t *testing.T) {
doneSet := map[string]bool{"T07": true}
task := supervisor.Task{ID: "T08", Status: "backlog", DependsOn: []string{"T07"}}
if got := resolveTaskAction(task, doneSet); got != "review" {
t.Errorf("expected review, got %s", got)
}
}
func TestResolveTaskAction_Run(t *testing.T) {
task := supervisor.Task{ID: "T08", Status: "ready"}
if got := resolveTaskAction(task, nil); got != "run" {
t.Errorf("expected run, got %s", got)
}
}
func TestResolveTaskAction_Running(t *testing.T) {
task := supervisor.Task{ID: "T08", Status: "active"}
if got := resolveTaskAction(task, nil); got != "running" {
t.Errorf("expected running, got %s", got)
}
}
func TestResolveTaskAction_Approve(t *testing.T) {
task := supervisor.Task{ID: "T08", Status: "review"}
if got := resolveTaskAction(task, nil); got != "approve" {
t.Errorf("expected approve, got %s", got)
}
}
func TestResolveTaskAction_Done(t *testing.T) {
task := supervisor.Task{ID: "T01", Status: "done"}
if got := resolveTaskAction(task, nil); got != "done" {
t.Errorf("expected done, got %s", got)
}
}
func TestDashboardHTML_BlockedButton(t *testing.T) {
srv := setupTestServer(t)
// T08 in backlog with dep T07 not in done → blocked
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "Blokiran") {
t.Error("expected 'Blokiran' for backlog task with unmet deps")
}
if !containsStr(body, "btn-blocked") {
t.Error("expected btn-blocked class")
}
}
func TestDashboardHTML_DoneReportButton(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
// T01 is in done → should have "Izvestaj" button
if !containsStr(body, "btn-report") {
t.Error("expected btn-report for done task")
}
}
func TestSSE_EventsEndpoint(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/events", nil)
w := httptest.NewRecorder()
// Use a context with cancel to stop the SSE handler
ctx, cancel := req.Context(), func() {}
_ = ctx
_ = cancel
// Just check the handler starts without error and sets correct headers
go func() {
srv.Router.ServeHTTP(w, req)
}()
time.Sleep(100 * time.Millisecond)
if ct := w.Header().Get("Content-Type"); ct != "text/event-stream" {
t.Errorf("expected Content-Type text/event-stream, got %s", ct)
}
}
func TestHashTaskState(t *testing.T) {
tasks1 := []supervisor.Task{
{ID: "T01", Status: "done"},
{ID: "T02", Status: "backlog"},
}
tasks2 := []supervisor.Task{
{ID: "T01", Status: "done"},
{ID: "T02", Status: "ready"}, // changed
}
tasks3 := []supervisor.Task{
{ID: "T02", Status: "backlog"},
{ID: "T01", Status: "done"}, // same as tasks1 but different order
}
h1 := hashTaskState(tasks1)
h2 := hashTaskState(tasks2)
h3 := hashTaskState(tasks3)
if h1 == h2 {
t.Error("hash should differ when task status changes")
}
if h1 != h3 {
t.Error("hash should be same regardless of task order")
}
}
func TestSSE_BroadcastOnChange(t *testing.T) {
srv := setupTestServer(t)
// Subscribe a client
ch := srv.events.subscribe()
defer srv.events.unsubscribe(ch)
// Trigger a check — first call sets the baseline hash and broadcasts
srv.events.checkAndBroadcast(func() string { return "board-html" })
// Drain initial broadcast
select {
case <-ch:
case <-time.After(100 * time.Millisecond):
}
// Move a task to change state
os.Rename(
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
filepath.Join(srv.Config.TasksDir, "ready", "T08.md"),
)
// Trigger another check — state changed, should broadcast
srv.events.checkAndBroadcast(func() string { return "updated-board" })
select {
case data := <-ch:
if data != "updated-board" {
t.Errorf("expected 'updated-board', got %s", data)
}
case <-time.After(time.Second):
t.Error("expected broadcast after state change, got nothing")
}
}
func TestSSE_NoBroadcastWithoutChange(t *testing.T) {
srv := setupTestServer(t)
ch := srv.events.subscribe()
defer srv.events.unsubscribe(ch)
// Two checks without changes — second should not broadcast
srv.events.checkAndBroadcast(func() string { return "board" })
// Drain the first broadcast (initial hash set)
select {
case <-ch:
case <-time.After(100 * time.Millisecond):
}
srv.events.checkAndBroadcast(func() string { return "board" })
select {
case <-ch:
t.Error("should not broadcast when state hasn't changed")
case <-time.After(100 * time.Millisecond):
// Good — no broadcast
}
}
func TestConsolePage(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/console", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if !containsStr(body, "Sesija 1") {
t.Error("expected 'Sesija 1' in console page")
}
if !containsStr(body, "Sesija 2") {
t.Error("expected 'Sesija 2' in console page")
}
}
func TestConsoleSessions(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 statuses []sessionStatus
if err := json.Unmarshal(w.Body.Bytes(), &statuses); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(statuses) != 2 {
t.Fatalf("expected 2 sessions, got %d", len(statuses))
}
if statuses[0].Status != "idle" || statuses[1].Status != "idle" {
t.Error("expected both sessions idle")
}
}
func TestConsoleExec_InvalidSession(t *testing.T) {
srv := setupTestServer(t)
body := `{"cmd":"status","session":3}`
req := httptest.NewRequest(http.MethodPost, "/console/exec", 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 invalid session, got %d", w.Code)
}
}
func TestConsoleExec_ValidRequest(t *testing.T) {
srv := setupTestServer(t)
body := `{"cmd":"echo test","session":1}`
req := httptest.NewRequest(http.MethodPost, "/console/exec", 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 execResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if resp.ExecID == "" {
t.Error("expected non-empty exec ID")
}
if resp.Session != 1 {
t.Errorf("expected session 1, got %d", resp.Session)
}
}
func TestConsoleKill_IdleSession(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodPost, "/console/kill/1", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
func TestConsoleHistory_Empty(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/console/history/1", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var history []historyEntry
if err := json.Unmarshal(w.Body.Bytes(), &history); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(history) != 0 {
t.Errorf("expected empty history, got %d entries", len(history))
}
}
func TestConsoleHistory_AfterExec(t *testing.T) {
srv := setupTestServer(t)
// Execute a command first
body := `{"cmd":"test command","session":2}`
req := httptest.NewRequest(http.MethodPost, "/console/exec", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
// Check history
req2 := httptest.NewRequest(http.MethodGet, "/console/history/2", nil)
w2 := httptest.NewRecorder()
srv.Router.ServeHTTP(w2, req2)
var history []historyEntry
json.Unmarshal(w2.Body.Bytes(), &history)
if len(history) != 1 {
t.Fatalf("expected 1 history entry, got %d", len(history))
}
if history[0].Command != "test command" {
t.Errorf("expected 'test command', got %s", history[0].Command)
}
}
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)
}
}
// --- T22: Submit tests ---
func TestSubmitPage(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/submit", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if !containsStr(body, "Klijent") {
t.Error("expected 'Klijent' mode button")
}
if !containsStr(body, "Operater") {
t.Error("expected 'Operater' mode button")
}
if !containsStr(body, "mode-client") {
t.Error("expected client mode section")
}
}
func TestSubmitPage_ClientModeIsDefault(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/submit", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
// Operator mode should be hidden by default
if !containsStr(body, `id="mode-operator" class="submit-mode" style="display:none"`) {
t.Error("expected operator mode to be hidden by default")
}
}
func TestSimpleSubmit_CreatesTask(t *testing.T) {
srv := setupTestServer(t)
form := strings.NewReader("title=Test+prijava&description=Opis+testa&priority=Visok")
req := httptest.NewRequest(http.MethodPost, "/submit/simple", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["status"] != "ok" {
t.Errorf("expected status ok, got %v", resp["status"])
}
taskID, ok := resp["task_id"].(string)
if !ok || taskID == "" {
t.Fatal("expected non-empty task_id")
}
// Verify file was created in backlog
path := filepath.Join(srv.Config.TasksDir, "backlog", taskID+".md")
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("expected task file in backlog: %v", err)
}
if !containsStr(string(content), "Test prijava") {
t.Error("expected title in task file")
}
if !containsStr(string(content), "Visok") {
t.Error("expected priority in task file")
}
if !containsStr(string(content), "klijent (prijava)") {
t.Error("expected 'klijent (prijava)' as creator")
}
}
func TestSimpleSubmit_MissingTitle(t *testing.T) {
srv := setupTestServer(t)
form := strings.NewReader("description=Samo+opis")
req := httptest.NewRequest(http.MethodPost, "/submit/simple", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for missing title, got %d", w.Code)
}
}
func TestSimpleSubmit_AutoNumbering(t *testing.T) {
srv := setupTestServer(t)
// Existing tasks: T01 (done), T08 (backlog)
// Next should be T09
form := strings.NewReader("title=Novi+task&priority=Srednji")
req := httptest.NewRequest(http.MethodPost, "/submit/simple", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
taskID, _ := resp["task_id"].(string)
if taskID != "T09" {
t.Errorf("expected T09 (next after T08), got %s", taskID)
}
}
func TestSimpleSubmit_DefaultPriority(t *testing.T) {
srv := setupTestServer(t)
form := strings.NewReader("title=Bez+prioriteta")
req := httptest.NewRequest(http.MethodPost, "/submit/simple", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
taskID, _ := resp["task_id"].(string)
path := filepath.Join(srv.Config.TasksDir, "backlog", taskID+".md")
content, _ := os.ReadFile(path)
if !containsStr(string(content), "Srednji") {
t.Error("expected default priority 'Srednji'")
}
}
func TestChatSubmit_ReturnsChatID(t *testing.T) {
srv := setupTestServer(t)
body := `{"message":"test poruka"}`
req := httptest.NewRequest(http.MethodPost, "/submit/chat", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["chat_id"] == nil || resp["chat_id"] == "" {
t.Error("expected non-empty chat_id in response")
}
}
func TestChatSubmit_EmptyMessage(t *testing.T) {
srv := setupTestServer(t)
body := `{"message":""}`
req := httptest.NewRequest(http.MethodPost, "/submit/chat", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for empty message, got %d", w.Code)
}
}
func TestChatStream_NotFound(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/submit/chat/stream/nonexistent", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestNextTaskNumber(t *testing.T) {
dir := t.TempDir()
tasksDir := filepath.Join(dir, "TASKS")
for _, f := range []string{"backlog", "ready", "active", "review", "done"} {
os.MkdirAll(filepath.Join(tasksDir, f), 0755)
}
os.WriteFile(filepath.Join(tasksDir, "done", "T01.md"), []byte(testTask1), 0644)
os.WriteFile(filepath.Join(tasksDir, "backlog", "T08.md"), []byte(testTask2), 0644)
num, err := nextTaskNumber(tasksDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if num != "T09" {
t.Errorf("expected T09, got %s", num)
}
}
func TestBuildTaskContext(t *testing.T) {
tasks := []supervisor.Task{
{ID: "T01", Title: "Init", Status: "done", Agent: "coder", Model: "Sonnet"},
{ID: "T02", Title: "Server", Status: "active", Agent: "coder", Model: "Opus"},
}
ctx := buildTaskContext(tasks)
if !containsStr(ctx, "T01: Init") {
t.Error("expected T01 in context")
}
if !containsStr(ctx, "T02: Server") {
t.Error("expected T02 in context")
}
if !containsStr(ctx, "DONE") {
t.Error("expected DONE section")
}
}
func TestSubmitPage_HasPrijavaNav(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/submit", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, `href="/submit"`) {
t.Error("expected Prijava nav link")
}
}
func TestDashboard_HasPrijavaNav(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, `href="/submit"`) {
t.Error("expected Prijava nav link in dashboard")
}
}
// --- T21: UI tests ---
func TestDashboard_EscapeClosesOverlay(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "Escape") {
t.Error("expected Escape key handler for overlay")
}
}
func TestDashboard_BackdropClickClosesOverlay(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "e.target === this") {
t.Error("expected backdrop click handler for overlay")
}
}
func TestTaskDetail_HasInnerWrapper(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/task/T01", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "detail-inner") {
t.Error("expected detail-inner wrapper in task detail")
}
}
func TestTaskDetail_RendersMarkdownAsHTML(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/task/T01", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
// Markdown headers should be rendered as HTML <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 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_HasSessionToggle(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, "togglePanel2") {
t.Error("expected togglePanel2 button in console page")
}
if !containsStr(body, `+ Sesija 2`) {
t.Error("expected '+ Sesija 2' toggle button")
}
}
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")
}
}
// --- T24: PTY tests ---
func TestStripAnsi(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"hello", "hello"},
{"\x1b[32mgreen\x1b[0m", "green"},
{"\x1b[1;31mbold red\x1b[0m", "bold red"},
{"\x1b[?25l\x1b[?25h", ""},
{"no \x1b[4munderline\x1b[24m here", "no underline here"},
{"\x1b]0;title\x07text", "text"},
}
for _, tt := range tests {
got := stripAnsi(tt.input)
if got != tt.expected {
t.Errorf("stripAnsi(%q) = %q, want %q", tt.input, got, tt.expected)
}
}
}
func TestReadPTY_SplitsLines(t *testing.T) {
r, w, _ := os.Pipe()
var lines []string
done := make(chan bool)
go func() {
readPTY(r, func(line string) {
lines = append(lines, line)
})
done <- true
}()
w.WriteString("line1\nline2\nline3\n")
w.Close()
<-done
if len(lines) != 3 {
t.Fatalf("expected 3 lines, got %d: %v", len(lines), lines)
}
if lines[0] != "line1" || lines[1] != "line2" || lines[2] != "line3" {
t.Errorf("unexpected lines: %v", lines)
}
}
func TestReadPTY_StripsAnsi(t *testing.T) {
r, w, _ := os.Pipe()
var lines []string
done := make(chan bool)
go func() {
readPTY(r, func(line string) {
lines = append(lines, line)
})
done <- true
}()
w.WriteString("\x1b[32mcolored\x1b[0m\n")
w.Close()
<-done
if len(lines) != 1 {
t.Fatalf("expected 1 line, got %d", len(lines))
}
if lines[0] != "colored" {
t.Errorf("expected 'colored', got %q", lines[0])
}
}
func TestReadPTY_HandlesPartialChunks(t *testing.T) {
r, w, _ := os.Pipe()
var lines []string
done := make(chan bool)
go func() {
readPTY(r, func(line string) {
lines = append(lines, line)
})
done <- true
}()
// Write partial, then complete
w.WriteString("partial")
w.Close()
<-done
if len(lines) != 1 {
t.Fatalf("expected 1 line for partial, got %d: %v", len(lines), lines)
}
if lines[0] != "partial" {
t.Errorf("expected 'partial', got %q", lines[0])
}
}
func TestReadPTY_HandlesCarriageReturn(t *testing.T) {
r, w, _ := os.Pipe()
var lines []string
done := make(chan bool)
go func() {
readPTY(r, func(line string) {
lines = append(lines, line)
})
done <- true
}()
w.WriteString("line1\r\nline2\r\n")
w.Close()
<-done
if len(lines) != 2 {
t.Fatalf("expected 2 lines, got %d: %v", len(lines), lines)
}
if lines[0] != "line1" || lines[1] != "line2" {
t.Errorf("unexpected lines: %v", lines)
}
}
func containsStr(s, substr string) bool {
return len(s) >= len(substr) && findStr(s, substr)
}
func findStr(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}