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