Add Convoy Tracking Web UI Dashboard (hq-vr35)
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 <noreply@anthropic.com>
This commit is contained in:
397
internal/web/browser_e2e_test.go
Normal file
397
internal/web/browser_e2e_test.go
Normal file
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user