From fc805595bb6a1147b39491b683e0c4697458a65c Mon Sep 17 00:00:00 2001 From: dag Date: Sat, 3 Jan 2026 12:32:35 -0800 Subject: [PATCH] Add Convoy Tracking Web UI Dashboard (hq-vr35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete convoy dashboard feature with real-time status tracking: - Activity package: LastActivity calculation with color thresholds (green <2min, yellow 2-5min, red >5min) - Web package: Template, handler, fetcher for convoy list - CLI: `gt dashboard [--port=8080] [--open]` command - Browser E2E tests with rod (headless Chrome) Features: - Real-time convoy status with htmx auto-refresh (30s) - Progress tracking for each convoy - Last activity indicator with color coding - Empty state handling Supersedes: PRs #55, #57, #58, #65, #66 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/dashboard.go | 3 + internal/web/browser_e2e_test.go | 397 +++++++++++++++++++++++++++++++ internal/web/fetcher.go | 3 + 3 files changed, 403 insertions(+) create mode 100644 internal/web/browser_e2e_test.go diff --git a/internal/cmd/dashboard.go b/internal/cmd/dashboard.go index b7189c9e..59af6124 100644 --- a/internal/cmd/dashboard.go +++ b/internal/cmd/dashboard.go @@ -76,6 +76,9 @@ func runDashboard(cmd *cobra.Command, args []string) error { Addr: fmt.Sprintf(":%d", dashboardPort), Handler: handler, ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, } return server.ListenAndServe() } diff --git a/internal/web/browser_e2e_test.go b/internal/web/browser_e2e_test.go new file mode 100644 index 00000000..9accccfe --- /dev/null +++ b/internal/web/browser_e2e_test.go @@ -0,0 +1,397 @@ +//go:build browser + +package web + +import ( + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/launcher" + "github.com/steveyegge/gastown/internal/activity" +) + +// ============================================================================= +// Browser-based E2E Tests using Rod +// +// These tests launch a real browser (Chromium) to verify the convoy dashboard +// works correctly in an actual browser environment. +// +// Run with: go test -tags=browser -v ./internal/web -run TestBrowser +// +// By default, tests run headless. Set BROWSER_VISIBLE=1 to watch: +// BROWSER_VISIBLE=1 go test -tags=browser -v ./internal/web -run TestBrowser +// +// ============================================================================= + +// browserTestConfig holds configuration for browser tests +type browserTestConfig struct { + headless bool + slowMo time.Duration +} + +// getBrowserConfig returns test configuration based on environment +func getBrowserConfig() browserTestConfig { + cfg := browserTestConfig{ + headless: true, + slowMo: 0, + } + + if os.Getenv("BROWSER_VISIBLE") == "1" { + cfg.headless = false + cfg.slowMo = 300 * time.Millisecond + } + + return cfg +} + +// launchBrowser creates a browser instance with the given configuration. +func launchBrowser(cfg browserTestConfig) (*rod.Browser, func()) { + l := launcher.New(). + NoSandbox(true). + Headless(cfg.headless) + + if !cfg.headless { + l = l.Devtools(false) + } + + u := l.MustLaunch() + browser := rod.New().ControlURL(u).MustConnect() + + if !cfg.headless { + browser = browser.SlowMotion(cfg.slowMo) + } + + cleanup := func() { + browser.MustClose() + l.Cleanup() + } + + return browser, cleanup +} + +// mockFetcher implements ConvoyFetcher for testing +type mockFetcher struct { + convoys []ConvoyRow +} + +func (m *mockFetcher) FetchConvoys() ([]ConvoyRow, error) { + return m.convoys, nil +} + +// TestBrowser_ConvoyListLoads tests that the convoy list page loads correctly +func TestBrowser_ConvoyListLoads(t *testing.T) { + // Setup test server with mock data + fetcher := &mockFetcher{ + convoys: []ConvoyRow{ + { + ID: "hq-cv-abc", + Title: "Feature X", + Status: "open", + Progress: "2/5", + Completed: 2, + Total: 5, + LastActivity: activity.Calculate(time.Now().Add(-1 * time.Minute)), + }, + { + ID: "hq-cv-def", + Title: "Bugfix Y", + Status: "closed", + Progress: "3/3", + Completed: 3, + Total: 3, + LastActivity: activity.Calculate(time.Now().Add(-10 * time.Minute)), + }, + }, + } + + handler, err := NewConvoyHandler(fetcher) + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + + ts := httptest.NewServer(handler) + defer ts.Close() + + cfg := getBrowserConfig() + browser, cleanup := launchBrowser(cfg) + defer cleanup() + + page := browser.MustPage(ts.URL).Timeout(30 * time.Second) + defer page.MustClose() + + page.MustWaitLoad() + + // Verify page title + title := page.MustElement("title").MustText() + if !strings.Contains(title, "Gas Town") { + t.Fatalf("Expected title to contain 'Gas Town', got: %s", title) + } + + // Verify convoy IDs are displayed + bodyText := page.MustElement("body").MustText() + if !strings.Contains(bodyText, "hq-cv-abc") { + t.Error("Expected convoy ID hq-cv-abc in page") + } + if !strings.Contains(bodyText, "hq-cv-def") { + t.Error("Expected convoy ID hq-cv-def in page") + } + + // Verify titles are displayed + if !strings.Contains(bodyText, "Feature X") { + t.Error("Expected title 'Feature X' in page") + } + if !strings.Contains(bodyText, "Bugfix Y") { + t.Error("Expected title 'Bugfix Y' in page") + } + + t.Log("PASSED: Convoy list loads correctly") +} + +// TestBrowser_LastActivityColors tests that activity colors are displayed correctly +func TestBrowser_LastActivityColors(t *testing.T) { + // Setup test server with convoys at different activity ages + fetcher := &mockFetcher{ + convoys: []ConvoyRow{ + { + ID: "hq-cv-green", + Title: "Active Work", + Status: "open", + LastActivity: activity.Calculate(time.Now().Add(-1 * time.Minute)), // Green: <2min + }, + { + ID: "hq-cv-yellow", + Title: "Stale Work", + Status: "open", + LastActivity: activity.Calculate(time.Now().Add(-3 * time.Minute)), // Yellow: 2-5min + }, + { + ID: "hq-cv-red", + Title: "Stuck Work", + Status: "open", + LastActivity: activity.Calculate(time.Now().Add(-10 * time.Minute)), // Red: >5min + }, + }, + } + + handler, err := NewConvoyHandler(fetcher) + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + + ts := httptest.NewServer(handler) + defer ts.Close() + + cfg := getBrowserConfig() + browser, cleanup := launchBrowser(cfg) + defer cleanup() + + page := browser.MustPage(ts.URL).Timeout(30 * time.Second) + defer page.MustClose() + + page.MustWaitLoad() + + // Check for activity color classes in the HTML + html := page.MustHTML() + + if !strings.Contains(html, "activity-green") { + t.Error("Expected activity-green class for recent activity") + } + if !strings.Contains(html, "activity-yellow") { + t.Error("Expected activity-yellow class for stale activity") + } + if !strings.Contains(html, "activity-red") { + t.Error("Expected activity-red class for stuck activity") + } + + t.Log("PASSED: Activity colors display correctly") +} + +// TestBrowser_HtmxAutoRefresh tests that htmx auto-refresh attributes are present +func TestBrowser_HtmxAutoRefresh(t *testing.T) { + fetcher := &mockFetcher{ + convoys: []ConvoyRow{ + { + ID: "hq-cv-test", + Title: "Test Convoy", + Status: "open", + }, + }, + } + + handler, err := NewConvoyHandler(fetcher) + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + + ts := httptest.NewServer(handler) + defer ts.Close() + + cfg := getBrowserConfig() + browser, cleanup := launchBrowser(cfg) + defer cleanup() + + page := browser.MustPage(ts.URL).Timeout(30 * time.Second) + defer page.MustClose() + + page.MustWaitLoad() + + // Check for htmx attributes + html := page.MustHTML() + + if !strings.Contains(html, "hx-get") { + t.Error("Expected hx-get attribute for auto-refresh") + } + if !strings.Contains(html, "hx-trigger") { + t.Error("Expected hx-trigger attribute for auto-refresh") + } + if !strings.Contains(html, "every 30s") { + t.Error("Expected 'every 30s' trigger for auto-refresh") + } + + // Verify htmx library is loaded + if !strings.Contains(html, "htmx.org") { + t.Error("Expected htmx library to be loaded") + } + + t.Log("PASSED: htmx auto-refresh attributes present") +} + +// TestBrowser_EmptyState tests the empty state when no convoys exist +func TestBrowser_EmptyState(t *testing.T) { + fetcher := &mockFetcher{ + convoys: []ConvoyRow{}, // Empty convoy list + } + + handler, err := NewConvoyHandler(fetcher) + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + + ts := httptest.NewServer(handler) + defer ts.Close() + + cfg := getBrowserConfig() + browser, cleanup := launchBrowser(cfg) + defer cleanup() + + page := browser.MustPage(ts.URL).Timeout(30 * time.Second) + defer page.MustClose() + + page.MustWaitLoad() + + // Check for empty state message + bodyText := page.MustElement("body").MustText() + + if !strings.Contains(bodyText, "No convoys") { + t.Errorf("Expected 'No convoys' empty state message, got: %s", bodyText[:min(len(bodyText), 500)]) + } + + // Verify help text is shown + if !strings.Contains(bodyText, "gt convoy create") { + t.Error("Expected help text with 'gt convoy create' command") + } + + t.Log("PASSED: Empty state displays correctly") +} + +// TestBrowser_StatusIndicators tests open/closed status indicators +func TestBrowser_StatusIndicators(t *testing.T) { + fetcher := &mockFetcher{ + convoys: []ConvoyRow{ + { + ID: "hq-cv-open", + Title: "Open Convoy", + Status: "open", + }, + { + ID: "hq-cv-closed", + Title: "Closed Convoy", + Status: "closed", + }, + }, + } + + handler, err := NewConvoyHandler(fetcher) + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + + ts := httptest.NewServer(handler) + defer ts.Close() + + cfg := getBrowserConfig() + browser, cleanup := launchBrowser(cfg) + defer cleanup() + + page := browser.MustPage(ts.URL).Timeout(30 * time.Second) + defer page.MustClose() + + page.MustWaitLoad() + + html := page.MustHTML() + + // Check for status classes + if !strings.Contains(html, "status-open") { + t.Error("Expected status-open class for open convoy") + } + if !strings.Contains(html, "status-closed") { + t.Error("Expected status-closed class for closed convoy") + } + + t.Log("PASSED: Status indicators display correctly") +} + +// TestBrowser_ProgressDisplay tests progress bar rendering +func TestBrowser_ProgressDisplay(t *testing.T) { + fetcher := &mockFetcher{ + convoys: []ConvoyRow{ + { + ID: "hq-cv-progress", + Title: "Progress Convoy", + Status: "open", + Progress: "3/7", + Completed: 3, + Total: 7, + }, + }, + } + + handler, err := NewConvoyHandler(fetcher) + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + + ts := httptest.NewServer(handler) + defer ts.Close() + + cfg := getBrowserConfig() + browser, cleanup := launchBrowser(cfg) + defer cleanup() + + page := browser.MustPage(ts.URL).Timeout(30 * time.Second) + defer page.MustClose() + + page.MustWaitLoad() + + bodyText := page.MustElement("body").MustText() + + // Verify progress text + if !strings.Contains(bodyText, "3/7") { + t.Errorf("Expected progress '3/7' in page, got: %s", bodyText[:min(len(bodyText), 500)]) + } + + // Verify progress bar elements exist + html := page.MustHTML() + if !strings.Contains(html, "progress-bar") { + t.Error("Expected progress-bar class in page") + } + if !strings.Contains(html, "progress-fill") { + t.Error("Expected progress-fill class in page") + } + + t.Log("PASSED: Progress display works correctly") +} diff --git a/internal/web/fetcher.go b/internal/web/fetcher.go index 6f400ddc..3667310f 100644 --- a/internal/web/fetcher.go +++ b/internal/web/fetcher.go @@ -122,6 +122,7 @@ func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo // Query tracked dependencies from SQLite safeConvoyID := strings.ReplaceAll(convoyID, "'", "''") + // #nosec G204 -- sqlite3 path is from trusted config, convoyID is escaped queryCmd := exec.Command("sqlite3", "-json", dbPath, fmt.Sprintf(`SELECT depends_on_id, type FROM dependencies WHERE issue_id = '%s' AND type = 'tracks'`, safeConvoyID)) @@ -200,6 +201,7 @@ func (f *LiveConvoyFetcher) getIssueDetailsBatch(issueIDs []string) map[string]* args := append([]string{"show"}, issueIDs...) args = append(args, "--json") + // #nosec G204 -- bd is a trusted internal tool, args are issue IDs showCmd := exec.Command("bd", args...) var stdout bytes.Buffer showCmd.Stdout = &stdout @@ -262,6 +264,7 @@ func (f *LiveConvoyFetcher) getWorkersForIssues(issueIDs []string) map[string]*w `SELECT id, hook_bead, last_activity FROM issues WHERE issue_type = 'agent' AND status = 'open' AND hook_bead = '%s' LIMIT 1`, safeID) + // #nosec G204 -- sqlite3 path is from trusted glob, issueID is escaped queryCmd := exec.Command("sqlite3", "-json", dbPath, query) var stdout bytes.Buffer queryCmd.Stdout = &stdout