Files
gastown/internal/web/browser_e2e_test.go
dag fc805595bb 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>
2026-01-03 16:23:44 -08:00

398 lines
9.5 KiB
Go

//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")
}