* feat(dashboard): comprehensive control panel with expand/collapse
- Add 13 panels: Convoys, Polecats, Sessions, Activity, Mail, Merge Queue,
Escalations, Rigs, Dogs, System Health, Open Issues, Hooks, Queues
- Add Mayor status banner and Summary/Alerts section
- Implement instant client-side expand/collapse (no page reload)
- Add responsive grid layout for different window sizes
- Parallel data fetching for faster load times
- Color-coded mail by sender, chronological ordering
- Full titles visible in expanded views (no truncation)
- Auto-refresh every 10 seconds via HTMX
* fix(web): update tests and lint for dashboard control panel
- Update MockConvoyFetcher with 11 new interface methods
- Update MockConvoyFetcherWithErrors with matching methods
- Update test assertions for new template structure:
- Section headers ("Gas Town Convoys" -> "Convoys")
- Work status badges (badge-green, badge-yellow, badge-red)
- CI/merge status display text
- Empty state messages ("No active convoys")
- Fix linting: explicit _, _ = for fmt.Sscanf returns
Tests and linting now pass with the new dashboard features.
* perf(web): add timeouts and error logging to dashboard
Performance and reliability improvements:
- Add 8-second overall fetch timeout to prevent stuck requests
- Add per-command timeouts: 5s for bd/sqlite3, 10s for gh, 2s for tmux
- Add helper functions runCmd() and runBdCmd() with context timeout
- Add error logging for all 14 fetch operations
- Handler now returns partial data if timeout occurs
This addresses slow loading and "stuck" dashboard issues by ensuring
commands cannot hang indefinitely.
1092 lines
27 KiB
Go
1092 lines
27 KiB
Go
package web
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/gastown/internal/activity"
|
|
)
|
|
|
|
// Test error for simulating fetch failures
|
|
var errFetchFailed = errors.New("fetch failed")
|
|
|
|
// MockConvoyFetcher is a mock implementation for testing.
|
|
type MockConvoyFetcher struct {
|
|
Convoys []ConvoyRow
|
|
MergeQueue []MergeQueueRow
|
|
Polecats []PolecatRow
|
|
Mail []MailRow
|
|
Rigs []RigRow
|
|
Dogs []DogRow
|
|
Escalations []EscalationRow
|
|
Health *HealthRow
|
|
Queues []QueueRow
|
|
Sessions []SessionRow
|
|
Hooks []HookRow
|
|
Mayor *MayorStatus
|
|
Issues []IssueRow
|
|
Activity []ActivityRow
|
|
Error error
|
|
}
|
|
|
|
func (m *MockConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
|
|
return m.Convoys, m.Error
|
|
}
|
|
|
|
func (m *MockConvoyFetcher) FetchMergeQueue() ([]MergeQueueRow, error) {
|
|
return m.MergeQueue, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcher) FetchPolecats() ([]PolecatRow, error) {
|
|
return m.Polecats, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcher) FetchMail() ([]MailRow, error) {
|
|
return m.Mail, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcher) FetchRigs() ([]RigRow, error) {
|
|
return m.Rigs, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcher) FetchDogs() ([]DogRow, error) {
|
|
return m.Dogs, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcher) FetchEscalations() ([]EscalationRow, error) {
|
|
return m.Escalations, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcher) FetchHealth() (*HealthRow, error) {
|
|
return m.Health, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcher) FetchQueues() ([]QueueRow, error) {
|
|
return m.Queues, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcher) FetchSessions() ([]SessionRow, error) {
|
|
return m.Sessions, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcher) FetchHooks() ([]HookRow, error) {
|
|
return m.Hooks, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcher) FetchMayor() (*MayorStatus, error) {
|
|
return m.Mayor, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcher) FetchIssues() ([]IssueRow, error) {
|
|
return m.Issues, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcher) FetchActivity() ([]ActivityRow, error) {
|
|
return m.Activity, nil
|
|
}
|
|
|
|
func TestConvoyHandler_RendersTemplate(t *testing.T) {
|
|
mock := &MockConvoyFetcher{
|
|
Convoys: []ConvoyRow{
|
|
{
|
|
ID: "hq-cv-abc",
|
|
Title: "Test Convoy",
|
|
Status: "open",
|
|
Progress: "2/5",
|
|
Completed: 2,
|
|
Total: 5,
|
|
LastActivity: activity.Calculate(time.Now().Add(-1 * time.Minute)),
|
|
},
|
|
},
|
|
}
|
|
|
|
handler, err := NewConvoyHandler(mock)
|
|
if err != nil {
|
|
t.Fatalf("NewConvoyHandler() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Status = %d, want %d", w.Code, http.StatusOK)
|
|
}
|
|
|
|
body := w.Body.String()
|
|
|
|
// Check convoy data is rendered
|
|
if !strings.Contains(body, "hq-cv-abc") {
|
|
t.Error("Response should contain convoy ID")
|
|
}
|
|
// Note: Convoy titles are no longer shown in the simplified dashboard table view
|
|
if !strings.Contains(body, "2/5") {
|
|
t.Error("Response should contain progress")
|
|
}
|
|
}
|
|
|
|
func TestConvoyHandler_LastActivityColors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
age time.Duration
|
|
wantClass string
|
|
}{
|
|
{"green for active", 30 * time.Second, "activity-green"},
|
|
{"yellow for stale", 3 * time.Minute, "activity-yellow"},
|
|
{"red for stuck", 10 * time.Minute, "activity-red"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
mock := &MockConvoyFetcher{
|
|
Convoys: []ConvoyRow{
|
|
{
|
|
ID: "hq-cv-test",
|
|
Title: "Test",
|
|
Status: "open",
|
|
LastActivity: activity.Calculate(time.Now().Add(-tt.age)),
|
|
},
|
|
},
|
|
}
|
|
|
|
handler, err := NewConvoyHandler(mock)
|
|
if err != nil {
|
|
t.Fatalf("NewConvoyHandler() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, tt.wantClass) {
|
|
t.Errorf("Response should contain %q", tt.wantClass)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConvoyHandler_EmptyConvoys(t *testing.T) {
|
|
mock := &MockConvoyFetcher{
|
|
Convoys: []ConvoyRow{},
|
|
}
|
|
|
|
handler, err := NewConvoyHandler(mock)
|
|
if err != nil {
|
|
t.Fatalf("NewConvoyHandler() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Status = %d, want %d", w.Code, http.StatusOK)
|
|
}
|
|
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, "No active convoys") {
|
|
t.Error("Response should show empty state message")
|
|
}
|
|
}
|
|
|
|
func TestConvoyHandler_ContentType(t *testing.T) {
|
|
mock := &MockConvoyFetcher{
|
|
Convoys: []ConvoyRow{},
|
|
}
|
|
|
|
handler, err := NewConvoyHandler(mock)
|
|
if err != nil {
|
|
t.Fatalf("NewConvoyHandler() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
contentType := w.Header().Get("Content-Type")
|
|
if !strings.Contains(contentType, "text/html") {
|
|
t.Errorf("Content-Type = %q, want text/html", contentType)
|
|
}
|
|
}
|
|
|
|
func TestConvoyHandler_MultipleConvoys(t *testing.T) {
|
|
mock := &MockConvoyFetcher{
|
|
Convoys: []ConvoyRow{
|
|
{ID: "hq-cv-1", Title: "First Convoy", Status: "open"},
|
|
{ID: "hq-cv-2", Title: "Second Convoy", Status: "closed"},
|
|
{ID: "hq-cv-3", Title: "Third Convoy", Status: "open"},
|
|
},
|
|
}
|
|
|
|
handler, err := NewConvoyHandler(mock)
|
|
if err != nil {
|
|
t.Fatalf("NewConvoyHandler() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
body := w.Body.String()
|
|
|
|
// Check all convoys are rendered
|
|
for _, id := range []string{"hq-cv-1", "hq-cv-2", "hq-cv-3"} {
|
|
if !strings.Contains(body, id) {
|
|
t.Errorf("Response should contain convoy %s", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Integration tests for error handling
|
|
// Note: The refactored dashboard handler treats fetch errors as non-fatal,
|
|
// rendering an empty section instead of returning an error.
|
|
|
|
func TestConvoyHandler_FetchConvoysError(t *testing.T) {
|
|
mock := &MockConvoyFetcher{
|
|
Error: errFetchFailed,
|
|
}
|
|
|
|
handler, err := NewConvoyHandler(mock)
|
|
if err != nil {
|
|
t.Fatalf("NewConvoyHandler() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
// Fetch errors are now non-fatal - the dashboard still renders
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Status = %d, want %d (fetch errors are non-fatal)", w.Code, http.StatusOK)
|
|
}
|
|
|
|
body := w.Body.String()
|
|
// Should show the empty state for convoys section
|
|
if !strings.Contains(body, "No active convoys") {
|
|
t.Error("Response should show empty state when fetch fails")
|
|
}
|
|
}
|
|
|
|
// Integration tests for merge queue rendering
|
|
|
|
func TestConvoyHandler_MergeQueueRendering(t *testing.T) {
|
|
mock := &MockConvoyFetcher{
|
|
Convoys: []ConvoyRow{},
|
|
MergeQueue: []MergeQueueRow{
|
|
{
|
|
Number: 123,
|
|
Repo: "roxas",
|
|
Title: "Fix authentication bug",
|
|
URL: "https://github.com/test/repo/pull/123",
|
|
CIStatus: "pass",
|
|
Mergeable: "ready",
|
|
ColorClass: "mq-green",
|
|
},
|
|
{
|
|
Number: 456,
|
|
Repo: "gastown",
|
|
Title: "Add dashboard feature",
|
|
URL: "https://github.com/test/repo/pull/456",
|
|
CIStatus: "pending",
|
|
Mergeable: "pending",
|
|
ColorClass: "mq-yellow",
|
|
},
|
|
},
|
|
}
|
|
|
|
handler, err := NewConvoyHandler(mock)
|
|
if err != nil {
|
|
t.Fatalf("NewConvoyHandler() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Status = %d, want %d", w.Code, http.StatusOK)
|
|
}
|
|
|
|
body := w.Body.String()
|
|
|
|
// Check merge queue section header
|
|
if !strings.Contains(body, "Merge Queue") {
|
|
t.Error("Response should contain merge queue section header")
|
|
}
|
|
|
|
// Check PR numbers are rendered
|
|
if !strings.Contains(body, "#123") {
|
|
t.Error("Response should contain PR #123")
|
|
}
|
|
if !strings.Contains(body, "#456") {
|
|
t.Error("Response should contain PR #456")
|
|
}
|
|
|
|
// Check repo names
|
|
if !strings.Contains(body, "roxas") {
|
|
t.Error("Response should contain repo 'roxas'")
|
|
}
|
|
|
|
// Check CI status badges (now display text, not classes)
|
|
if !strings.Contains(body, "CI Pass") {
|
|
t.Error("Response should contain 'CI Pass' text for passing PR")
|
|
}
|
|
if !strings.Contains(body, "CI Running") {
|
|
t.Error("Response should contain 'CI Running' text for pending PR")
|
|
}
|
|
}
|
|
|
|
func TestConvoyHandler_EmptyMergeQueue(t *testing.T) {
|
|
mock := &MockConvoyFetcher{
|
|
Convoys: []ConvoyRow{},
|
|
MergeQueue: []MergeQueueRow{},
|
|
}
|
|
|
|
handler, err := NewConvoyHandler(mock)
|
|
if err != nil {
|
|
t.Fatalf("NewConvoyHandler() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
body := w.Body.String()
|
|
|
|
// Should show empty state for merge queue
|
|
if !strings.Contains(body, "No PRs in queue") {
|
|
t.Error("Response should show empty merge queue message")
|
|
}
|
|
}
|
|
|
|
// Integration tests for polecat workers rendering
|
|
|
|
func TestConvoyHandler_PolecatWorkersRendering(t *testing.T) {
|
|
mock := &MockConvoyFetcher{
|
|
Convoys: []ConvoyRow{},
|
|
Polecats: []PolecatRow{
|
|
{
|
|
Name: "dag",
|
|
Rig: "roxas",
|
|
SessionID: "gt-roxas-dag",
|
|
LastActivity: activity.Calculate(time.Now().Add(-30 * time.Second)),
|
|
StatusHint: "Running tests...",
|
|
},
|
|
{
|
|
Name: "nux",
|
|
Rig: "roxas",
|
|
SessionID: "gt-roxas-nux",
|
|
LastActivity: activity.Calculate(time.Now().Add(-5 * time.Minute)),
|
|
StatusHint: "Waiting for input",
|
|
},
|
|
},
|
|
}
|
|
|
|
handler, err := NewConvoyHandler(mock)
|
|
if err != nil {
|
|
t.Fatalf("NewConvoyHandler() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Status = %d, want %d", w.Code, http.StatusOK)
|
|
}
|
|
|
|
body := w.Body.String()
|
|
|
|
// Check polecat section header
|
|
if !strings.Contains(body, "Polecats") {
|
|
t.Error("Response should contain polecat section header")
|
|
}
|
|
|
|
// Check polecat names
|
|
if !strings.Contains(body, "dag") {
|
|
t.Error("Response should contain polecat 'dag'")
|
|
}
|
|
if !strings.Contains(body, "nux") {
|
|
t.Error("Response should contain polecat 'nux'")
|
|
}
|
|
|
|
// Check rig names
|
|
if !strings.Contains(body, "roxas") {
|
|
t.Error("Response should contain rig 'roxas'")
|
|
}
|
|
|
|
// Note: StatusHint is no longer displayed in the simplified dashboard view
|
|
|
|
// Check activity colors (dag should be green, nux should be yellow/red)
|
|
if !strings.Contains(body, "activity-green") {
|
|
t.Error("Response should contain activity-green for recent activity")
|
|
}
|
|
}
|
|
|
|
// Integration tests for work status rendering
|
|
|
|
func TestConvoyHandler_WorkStatusRendering(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
workStatus string
|
|
wantClass string
|
|
wantStatusText string
|
|
}{
|
|
{"complete status", "complete", "badge-green", "✓"},
|
|
{"active status", "active", "badge-green", "Active"},
|
|
{"stale status", "stale", "badge-yellow", "Stale"},
|
|
{"stuck status", "stuck", "badge-red", "Stuck"},
|
|
{"waiting status", "waiting", "badge-muted", "Wait"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
mock := &MockConvoyFetcher{
|
|
Convoys: []ConvoyRow{
|
|
{
|
|
ID: "hq-cv-test",
|
|
Title: "Test Convoy",
|
|
Status: "open",
|
|
WorkStatus: tt.workStatus,
|
|
Progress: "1/2",
|
|
Completed: 1,
|
|
Total: 2,
|
|
LastActivity: activity.Calculate(time.Now()),
|
|
},
|
|
},
|
|
}
|
|
|
|
handler, err := NewConvoyHandler(mock)
|
|
if err != nil {
|
|
t.Fatalf("NewConvoyHandler() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
body := w.Body.String()
|
|
|
|
// Check work status class is applied
|
|
if !strings.Contains(body, tt.wantClass) {
|
|
t.Errorf("Response should contain class %q for work status %q", tt.wantClass, tt.workStatus)
|
|
}
|
|
|
|
// Check work status text is displayed
|
|
if !strings.Contains(body, tt.wantStatusText) {
|
|
t.Errorf("Response should contain status text %q", tt.wantStatusText)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Integration tests for progress bar rendering
|
|
|
|
func TestConvoyHandler_ProgressBarRendering(t *testing.T) {
|
|
mock := &MockConvoyFetcher{
|
|
Convoys: []ConvoyRow{
|
|
{
|
|
ID: "hq-cv-progress",
|
|
Title: "Progress Test",
|
|
Status: "open",
|
|
WorkStatus: "active",
|
|
Progress: "3/4",
|
|
Completed: 3,
|
|
Total: 4,
|
|
LastActivity: activity.Calculate(time.Now()),
|
|
},
|
|
},
|
|
}
|
|
|
|
handler, err := NewConvoyHandler(mock)
|
|
if err != nil {
|
|
t.Fatalf("NewConvoyHandler() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
body := w.Body.String()
|
|
|
|
// Check progress text
|
|
if !strings.Contains(body, "3/4") {
|
|
t.Error("Response should contain progress '3/4'")
|
|
}
|
|
|
|
// Check progress bar element
|
|
if !strings.Contains(body, "progress-bar") {
|
|
t.Error("Response should contain progress-bar class")
|
|
}
|
|
|
|
// Check progress fill with percentage (75%)
|
|
if !strings.Contains(body, "progress-fill") {
|
|
t.Error("Response should contain progress-fill class")
|
|
}
|
|
if !strings.Contains(body, "width: 75%") {
|
|
t.Error("Response should contain 75% width for 3/4 progress")
|
|
}
|
|
}
|
|
|
|
// Integration test for HTMX auto-refresh
|
|
|
|
func TestConvoyHandler_HTMXAutoRefresh(t *testing.T) {
|
|
mock := &MockConvoyFetcher{
|
|
Convoys: []ConvoyRow{},
|
|
}
|
|
|
|
handler, err := NewConvoyHandler(mock)
|
|
if err != nil {
|
|
t.Fatalf("NewConvoyHandler() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
body := w.Body.String()
|
|
|
|
// Check htmx attributes for auto-refresh
|
|
if !strings.Contains(body, "hx-get") {
|
|
t.Error("Response should contain hx-get attribute for HTMX")
|
|
}
|
|
if !strings.Contains(body, "hx-trigger") {
|
|
t.Error("Response should contain hx-trigger attribute for HTMX")
|
|
}
|
|
if !strings.Contains(body, "every 10s") {
|
|
t.Error("Response should contain 'every 10s' trigger interval")
|
|
}
|
|
}
|
|
|
|
// Integration test for full dashboard with all sections
|
|
|
|
func TestConvoyHandler_FullDashboard(t *testing.T) {
|
|
mock := &MockConvoyFetcher{
|
|
Convoys: []ConvoyRow{
|
|
{
|
|
ID: "hq-cv-full",
|
|
Title: "Full Test Convoy",
|
|
Status: "open",
|
|
WorkStatus: "active",
|
|
Progress: "2/3",
|
|
Completed: 2,
|
|
Total: 3,
|
|
LastActivity: activity.Calculate(time.Now().Add(-1 * time.Minute)),
|
|
},
|
|
},
|
|
MergeQueue: []MergeQueueRow{
|
|
{
|
|
Number: 789,
|
|
Repo: "testrig",
|
|
Title: "Test PR",
|
|
CIStatus: "pass",
|
|
Mergeable: "ready",
|
|
ColorClass: "mq-green",
|
|
},
|
|
},
|
|
Polecats: []PolecatRow{
|
|
{
|
|
Name: "worker1",
|
|
Rig: "testrig",
|
|
SessionID: "gt-testrig-worker1",
|
|
LastActivity: activity.Calculate(time.Now()),
|
|
StatusHint: "Working...",
|
|
},
|
|
},
|
|
}
|
|
|
|
handler, err := NewConvoyHandler(mock)
|
|
if err != nil {
|
|
t.Fatalf("NewConvoyHandler() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Status = %d, want %d", w.Code, http.StatusOK)
|
|
}
|
|
|
|
body := w.Body.String()
|
|
|
|
// Verify all three sections are present
|
|
if !strings.Contains(body, "Convoys") {
|
|
t.Error("Response should contain convoy section")
|
|
}
|
|
if !strings.Contains(body, "hq-cv-full") {
|
|
t.Error("Response should contain convoy data")
|
|
}
|
|
if !strings.Contains(body, "Merge Queue") {
|
|
t.Error("Response should contain merge queue section")
|
|
}
|
|
if !strings.Contains(body, "#789") {
|
|
t.Error("Response should contain PR data")
|
|
}
|
|
if !strings.Contains(body, "Polecats") {
|
|
t.Error("Response should contain polecat section")
|
|
}
|
|
if !strings.Contains(body, "worker1") {
|
|
t.Error("Response should contain polecat data")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// End-to-End Tests with httptest.Server
|
|
// =============================================================================
|
|
|
|
// TestE2E_Server_FullDashboard tests the full dashboard using a real HTTP server.
|
|
func TestE2E_Server_FullDashboard(t *testing.T) {
|
|
mock := &MockConvoyFetcher{
|
|
Convoys: []ConvoyRow{
|
|
{
|
|
ID: "hq-cv-e2e",
|
|
Title: "E2E Test Convoy",
|
|
Status: "open",
|
|
WorkStatus: "active",
|
|
Progress: "2/4",
|
|
Completed: 2,
|
|
Total: 4,
|
|
LastActivity: activity.Calculate(time.Now().Add(-45 * time.Second)),
|
|
},
|
|
},
|
|
MergeQueue: []MergeQueueRow{
|
|
{
|
|
Number: 101,
|
|
Repo: "roxas",
|
|
Title: "E2E Test PR",
|
|
URL: "https://github.com/test/roxas/pull/101",
|
|
CIStatus: "pass",
|
|
Mergeable: "ready",
|
|
ColorClass: "mq-green",
|
|
},
|
|
},
|
|
Polecats: []PolecatRow{
|
|
{
|
|
Name: "furiosa",
|
|
Rig: "roxas",
|
|
SessionID: "gt-roxas-furiosa",
|
|
LastActivity: activity.Calculate(time.Now().Add(-30 * time.Second)),
|
|
StatusHint: "Running E2E tests",
|
|
},
|
|
},
|
|
}
|
|
|
|
handler, err := NewConvoyHandler(mock)
|
|
if err != nil {
|
|
t.Fatalf("NewConvoyHandler() error = %v", err)
|
|
}
|
|
|
|
// Create a real HTTP server
|
|
server := httptest.NewServer(handler)
|
|
defer server.Close()
|
|
|
|
// Make HTTP request to the server
|
|
resp, err := http.Get(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("HTTP GET failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Verify status code
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("Status = %d, want %d", resp.StatusCode, http.StatusOK)
|
|
}
|
|
|
|
// Verify content type
|
|
contentType := resp.Header.Get("Content-Type")
|
|
if !strings.Contains(contentType, "text/html") {
|
|
t.Errorf("Content-Type = %q, want text/html", contentType)
|
|
}
|
|
|
|
// Read and verify body
|
|
bodyBytes, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read response body: %v", err)
|
|
}
|
|
body := string(bodyBytes)
|
|
|
|
// Verify all three sections render
|
|
checks := []struct {
|
|
name string
|
|
content string
|
|
}{
|
|
{"Convoy section", "Convoys"},
|
|
{"Convoy ID", "hq-cv-e2e"},
|
|
{"Convoy progress", "2/4"},
|
|
{"Merge queue section", "Merge Queue"},
|
|
{"PR number", "#101"},
|
|
{"PR repo", "roxas"},
|
|
{"Polecat section", "Polecats"},
|
|
{"Polecat name", "furiosa"},
|
|
{"HTMX auto-refresh", `hx-trigger="every 10s"`},
|
|
}
|
|
|
|
for _, check := range checks {
|
|
if !strings.Contains(body, check.content) {
|
|
t.Errorf("%s: should contain %q", check.name, check.content)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestE2E_Server_ActivityColors tests activity color rendering via HTTP server.
|
|
func TestE2E_Server_ActivityColors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
age time.Duration
|
|
wantClass string
|
|
}{
|
|
{"green for recent", 20 * time.Second, "activity-green"},
|
|
{"yellow for stale", 3 * time.Minute, "activity-yellow"},
|
|
{"red for stuck", 8 * time.Minute, "activity-red"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
mock := &MockConvoyFetcher{
|
|
Polecats: []PolecatRow{
|
|
{
|
|
Name: "test-worker",
|
|
Rig: "test-rig",
|
|
SessionID: "gt-test-rig-test-worker",
|
|
LastActivity: activity.Calculate(time.Now().Add(-tt.age)),
|
|
StatusHint: "Testing",
|
|
},
|
|
},
|
|
}
|
|
|
|
handler, err := NewConvoyHandler(mock)
|
|
if err != nil {
|
|
t.Fatalf("NewConvoyHandler() error = %v", err)
|
|
}
|
|
|
|
server := httptest.NewServer(handler)
|
|
defer server.Close()
|
|
|
|
resp, err := http.Get(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("HTTP GET failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
body := string(bodyBytes)
|
|
|
|
if !strings.Contains(body, tt.wantClass) {
|
|
t.Errorf("Should contain activity class %q for age %v", tt.wantClass, tt.age)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestE2E_Server_MergeQueueEmpty tests that empty merge queue shows message.
|
|
func TestE2E_Server_MergeQueueEmpty(t *testing.T) {
|
|
mock := &MockConvoyFetcher{
|
|
Convoys: []ConvoyRow{},
|
|
MergeQueue: []MergeQueueRow{},
|
|
Polecats: []PolecatRow{},
|
|
}
|
|
|
|
handler, err := NewConvoyHandler(mock)
|
|
if err != nil {
|
|
t.Fatalf("NewConvoyHandler() error = %v", err)
|
|
}
|
|
|
|
server := httptest.NewServer(handler)
|
|
defer server.Close()
|
|
|
|
resp, err := http.Get(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("HTTP GET failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
body := string(bodyBytes)
|
|
|
|
// Section header should always be visible
|
|
if !strings.Contains(body, "Merge Queue") {
|
|
t.Error("Merge queue section should always be visible")
|
|
}
|
|
|
|
// Empty state message
|
|
if !strings.Contains(body, "No PRs in queue") {
|
|
t.Error("Should show 'No PRs in queue' when empty")
|
|
}
|
|
}
|
|
|
|
// TestE2E_Server_MergeQueueStatuses tests all PR status combinations.
|
|
func TestE2E_Server_MergeQueueStatuses(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ciStatus string
|
|
mergeable string
|
|
colorClass string
|
|
wantCI string
|
|
wantMerge string
|
|
}{
|
|
{"green when ready", "pass", "ready", "mq-green", "CI Pass", "Ready"},
|
|
{"red when CI fails", "fail", "ready", "mq-red", "CI Fail", "Ready"},
|
|
{"red when conflict", "pass", "conflict", "mq-red", "CI Pass", "Conflict"},
|
|
{"yellow when pending", "pending", "pending", "mq-yellow", "CI Running", "Pending"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
mock := &MockConvoyFetcher{
|
|
MergeQueue: []MergeQueueRow{
|
|
{
|
|
Number: 42,
|
|
Repo: "test",
|
|
Title: "Test PR",
|
|
URL: "https://github.com/test/test/pull/42",
|
|
CIStatus: tt.ciStatus,
|
|
Mergeable: tt.mergeable,
|
|
ColorClass: tt.colorClass,
|
|
},
|
|
},
|
|
}
|
|
|
|
handler, err := NewConvoyHandler(mock)
|
|
if err != nil {
|
|
t.Fatalf("NewConvoyHandler() error = %v", err)
|
|
}
|
|
|
|
server := httptest.NewServer(handler)
|
|
defer server.Close()
|
|
|
|
resp, err := http.Get(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("HTTP GET failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
body := string(bodyBytes)
|
|
|
|
if !strings.Contains(body, tt.colorClass) {
|
|
t.Errorf("Should contain row class %q", tt.colorClass)
|
|
}
|
|
if !strings.Contains(body, tt.wantCI) {
|
|
t.Errorf("Should contain CI text %q", tt.wantCI)
|
|
}
|
|
if !strings.Contains(body, tt.wantMerge) {
|
|
t.Errorf("Should contain merge text %q", tt.wantMerge)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestE2E_Server_HTMLStructure validates HTML document structure.
|
|
func TestE2E_Server_HTMLStructure(t *testing.T) {
|
|
mock := &MockConvoyFetcher{Convoys: []ConvoyRow{}}
|
|
|
|
handler, err := NewConvoyHandler(mock)
|
|
if err != nil {
|
|
t.Fatalf("NewConvoyHandler() error = %v", err)
|
|
}
|
|
|
|
server := httptest.NewServer(handler)
|
|
defer server.Close()
|
|
|
|
resp, err := http.Get(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("HTTP GET failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
body := string(bodyBytes)
|
|
|
|
// Validate HTML structure
|
|
elements := []string{
|
|
"<!DOCTYPE html>",
|
|
"<html",
|
|
"<head>",
|
|
"<title>Gas Town Dashboard</title>",
|
|
"htmx.org",
|
|
"<body>",
|
|
"</body>",
|
|
"</html>",
|
|
}
|
|
|
|
for _, elem := range elements {
|
|
if !strings.Contains(body, elem) {
|
|
t.Errorf("Should contain HTML element %q", elem)
|
|
}
|
|
}
|
|
|
|
// Validate CSS variables for theming
|
|
cssVars := []string{"--bg-dark", "--green", "--yellow", "--red"}
|
|
for _, v := range cssVars {
|
|
if !strings.Contains(body, v) {
|
|
t.Errorf("Should contain CSS variable %q", v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestE2E_Server_RefineryInPolecats tests that refinery appears in polecat workers.
|
|
func TestE2E_Server_RefineryInPolecats(t *testing.T) {
|
|
mock := &MockConvoyFetcher{
|
|
Polecats: []PolecatRow{
|
|
{
|
|
Name: "refinery",
|
|
Rig: "roxas",
|
|
SessionID: "gt-roxas-refinery",
|
|
LastActivity: activity.Calculate(time.Now().Add(-10 * time.Second)),
|
|
StatusHint: "Idle - Waiting for PRs",
|
|
},
|
|
{
|
|
Name: "dag",
|
|
Rig: "roxas",
|
|
SessionID: "gt-roxas-dag",
|
|
LastActivity: activity.Calculate(time.Now().Add(-30 * time.Second)),
|
|
StatusHint: "Working on feature",
|
|
},
|
|
},
|
|
}
|
|
|
|
handler, err := NewConvoyHandler(mock)
|
|
if err != nil {
|
|
t.Fatalf("NewConvoyHandler() error = %v", err)
|
|
}
|
|
|
|
server := httptest.NewServer(handler)
|
|
defer server.Close()
|
|
|
|
resp, err := http.Get(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("HTTP GET failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
body := string(bodyBytes)
|
|
|
|
// Refinery should appear in polecat workers
|
|
if !strings.Contains(body, "refinery") {
|
|
t.Error("Refinery should appear in polecat workers section")
|
|
}
|
|
// Note: StatusHint is no longer displayed in the simplified dashboard view
|
|
|
|
// Regular polecats should also appear
|
|
if !strings.Contains(body, "dag") {
|
|
t.Error("Regular polecat 'dag' should appear")
|
|
}
|
|
}
|
|
|
|
// Test that merge queue and polecat errors are non-fatal
|
|
|
|
type MockConvoyFetcherWithErrors struct {
|
|
Convoys []ConvoyRow
|
|
MergeQueueError error
|
|
PolecatsError error
|
|
}
|
|
|
|
func (m *MockConvoyFetcherWithErrors) FetchConvoys() ([]ConvoyRow, error) {
|
|
return m.Convoys, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcherWithErrors) FetchMergeQueue() ([]MergeQueueRow, error) {
|
|
return nil, m.MergeQueueError
|
|
}
|
|
|
|
func (m *MockConvoyFetcherWithErrors) FetchPolecats() ([]PolecatRow, error) {
|
|
return nil, m.PolecatsError
|
|
}
|
|
|
|
func (m *MockConvoyFetcherWithErrors) FetchMail() ([]MailRow, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcherWithErrors) FetchRigs() ([]RigRow, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcherWithErrors) FetchDogs() ([]DogRow, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcherWithErrors) FetchEscalations() ([]EscalationRow, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcherWithErrors) FetchHealth() (*HealthRow, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcherWithErrors) FetchQueues() ([]QueueRow, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcherWithErrors) FetchSessions() ([]SessionRow, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcherWithErrors) FetchHooks() ([]HookRow, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcherWithErrors) FetchMayor() (*MayorStatus, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcherWithErrors) FetchIssues() ([]IssueRow, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockConvoyFetcherWithErrors) FetchActivity() ([]ActivityRow, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func TestConvoyHandler_NonFatalErrors(t *testing.T) {
|
|
mock := &MockConvoyFetcherWithErrors{
|
|
Convoys: []ConvoyRow{
|
|
{ID: "hq-cv-test", Title: "Test", Status: "open", WorkStatus: "active"},
|
|
},
|
|
MergeQueueError: errFetchFailed,
|
|
PolecatsError: errFetchFailed,
|
|
}
|
|
|
|
handler, err := NewConvoyHandler(mock)
|
|
if err != nil {
|
|
t.Fatalf("NewConvoyHandler() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
// Should still return OK even if merge queue and polecats fail
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Status = %d, want %d (non-fatal errors should not fail request)", w.Code, http.StatusOK)
|
|
}
|
|
|
|
body := w.Body.String()
|
|
|
|
// Convoys should still render
|
|
if !strings.Contains(body, "hq-cv-test") {
|
|
t.Error("Response should contain convoy data even when other fetches fail")
|
|
}
|
|
}
|