Merge pull request #71 from michaellady/feature/convoy-dashboard

Add lint fixes and E2E tests for convoy dashboard
This commit is contained in:
Steve Yegge
2026-01-04 13:45:54 -08:00
committed by GitHub
11 changed files with 2463 additions and 72 deletions

View File

@@ -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()
}

View File

@@ -17,7 +17,9 @@ const (
ShellReadyTimeout = 5 * time.Second
// DefaultDebounceMs is the default debounce for SendKeys operations.
DefaultDebounceMs = 100
// 500ms is required for Claude Code to reliably process paste before Enter.
// See NudgeSession comment: "Wait 500ms for paste to complete (tested, required)"
DefaultDebounceMs = 500
// DefaultDisplayMs is the default duration for tmux display-message.
DefaultDisplayMs = 5000

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

View File

@@ -30,6 +30,7 @@ func NewLiveConvoyFetcher() (*LiveConvoyFetcher, error) {
}, nil
}
// FetchConvoys fetches all open convoys with their activity data.
func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
// List all open convoy-type issues
@@ -68,6 +69,8 @@ func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
row.Total = len(tracked)
var mostRecentActivity time.Time
var mostRecentUpdated time.Time
var hasAssignee bool
for _, t := range tracked {
if t.Status == "closed" {
row.Completed++
@@ -76,20 +79,50 @@ func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
if t.LastActivity.After(mostRecentActivity) {
mostRecentActivity = t.LastActivity
}
// Track most recent updated_at as fallback
if t.UpdatedAt.After(mostRecentUpdated) {
mostRecentUpdated = t.UpdatedAt
}
if t.Assignee != "" {
hasAssignee = true
}
}
row.Progress = fmt.Sprintf("%d/%d", row.Completed, row.Total)
// Calculate activity info from most recent worker activity
if !mostRecentActivity.IsZero() {
// Have active tmux session activity from assigned workers
row.LastActivity = activity.Calculate(mostRecentActivity)
} else if !hasAssignee {
// No assignees found in beads - try fallback to any running polecat activity
// This handles cases where bd update --assignee didn't persist or wasn't returned
if polecatActivity := f.getAllPolecatActivity(); polecatActivity != nil {
info := activity.Calculate(*polecatActivity)
info.FormattedAge = info.FormattedAge + " (polecat active)"
row.LastActivity = info
} else if !mostRecentUpdated.IsZero() {
// Fall back to issue updated_at if no polecats running
info := activity.Calculate(mostRecentUpdated)
info.FormattedAge = info.FormattedAge + " (unassigned)"
row.LastActivity = info
} else {
row.LastActivity = activity.Info{
FormattedAge: "unassigned",
ColorClass: activity.ColorUnknown,
}
}
} else {
// Has assignee but no active session
row.LastActivity = activity.Info{
FormattedAge: "no activity",
FormattedAge: "idle",
ColorClass: activity.ColorUnknown,
}
}
// Calculate work status based on progress and activity
row.WorkStatus = calculateWorkStatus(row.Completed, row.Total, row.LastActivity.ColorClass)
// Get tracked issues for expandable view
row.TrackedIssues = make([]TrackedIssue, len(tracked))
for i, t := range tracked {
@@ -114,6 +147,7 @@ type trackedIssueInfo struct {
Status string
Assignee string
LastActivity time.Time
UpdatedAt time.Time // Fallback for activity when no assignee
}
// getTrackedIssues fetches tracked issues for a convoy.
@@ -122,6 +156,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))
@@ -155,8 +190,8 @@ func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo
// Batch fetch issue details
details := f.getIssueDetailsBatch(issueIDs)
// Get worker info for activity timestamps
workers := f.getWorkersForIssues(issueIDs)
// Get worker activity from tmux sessions based on assignees
workers := f.getWorkersFromAssignees(details)
// Build result
result := make([]trackedIssueInfo, 0, len(issueIDs))
@@ -167,6 +202,7 @@ func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo
info.Title = d.Title
info.Status = d.Status
info.Assignee = d.Assignee
info.UpdatedAt = d.UpdatedAt
} else {
info.Title = "(external)"
info.Status = "unknown"
@@ -184,10 +220,11 @@ func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo
// issueDetail holds basic issue info.
type issueDetail struct {
ID string
Title string
Status string
Assignee string
ID string
Title string
Status string
Assignee string
UpdatedAt time.Time
}
// getIssueDetailsBatch fetches details for multiple issues.
@@ -200,6 +237,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
@@ -209,22 +247,30 @@ func (f *LiveConvoyFetcher) getIssueDetailsBatch(issueIDs []string) map[string]*
}
var issues []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Assignee string `json:"assignee"`
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Assignee string `json:"assignee"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
return result
}
for _, issue := range issues {
result[issue.ID] = &issueDetail{
detail := &issueDetail{
ID: issue.ID,
Title: issue.Title,
Status: issue.Status,
Assignee: issue.Assignee,
}
// Parse updated_at timestamp
if issue.UpdatedAt != "" {
if t, err := time.Parse(time.RFC3339, issue.UpdatedAt); err == nil {
detail.UpdatedAt = t
}
}
result[issue.ID] = detail
}
return result
@@ -236,62 +282,474 @@ type workerDetail struct {
LastActivity *time.Time
}
// getWorkersForIssues finds workers and their last activity for issues.
func (f *LiveConvoyFetcher) getWorkersForIssues(issueIDs []string) map[string]*workerDetail {
// getWorkersFromAssignees gets worker activity from tmux sessions based on issue assignees.
// Assignees are in format "rigname/polecats/polecatname" which maps to tmux session "gt-rigname-polecatname".
func (f *LiveConvoyFetcher) getWorkersFromAssignees(details map[string]*issueDetail) map[string]*workerDetail {
result := make(map[string]*workerDetail)
if len(issueIDs) == 0 {
// Collect unique assignees and map them to issue IDs
assigneeToIssues := make(map[string][]string)
for issueID, detail := range details {
if detail == nil || detail.Assignee == "" {
continue
}
assigneeToIssues[detail.Assignee] = append(assigneeToIssues[detail.Assignee], issueID)
}
if len(assigneeToIssues) == 0 {
return result
}
townRoot, _ := workspace.FindFromCwd()
if townRoot == "" {
return result
}
// For each unique assignee, look up tmux session activity
for assignee, issueIDs := range assigneeToIssues {
activity := f.getSessionActivityForAssignee(assignee)
if activity == nil {
continue
}
// Find all rig beads databases
rigDirs, _ := filepath.Glob(filepath.Join(townRoot, "*", "mayor", "rig", ".beads", "beads.db"))
for _, dbPath := range rigDirs {
// Apply this activity to all issues assigned to this worker
for _, issueID := range issueIDs {
if _, ok := result[issueID]; ok {
continue
result[issueID] = &workerDetail{
Worker: assignee,
LastActivity: activity,
}
safeID := strings.ReplaceAll(issueID, "'", "''")
query := fmt.Sprintf(
`SELECT id, hook_bead, last_activity FROM issues WHERE issue_type = 'agent' AND status = 'open' AND hook_bead = '%s' LIMIT 1`,
safeID)
queryCmd := exec.Command("sqlite3", "-json", dbPath, query)
var stdout bytes.Buffer
queryCmd.Stdout = &stdout
if err := queryCmd.Run(); err != nil {
continue
}
var agents []struct {
ID string `json:"id"`
HookBead string `json:"hook_bead"`
LastActivity string `json:"last_activity"`
}
if err := json.Unmarshal(stdout.Bytes(), &agents); err != nil || len(agents) == 0 {
continue
}
agent := agents[0]
detail := &workerDetail{
Worker: agent.ID,
}
if agent.LastActivity != "" {
if t, err := time.Parse(time.RFC3339, agent.LastActivity); err == nil {
detail.LastActivity = &t
}
}
result[issueID] = detail
}
}
return result
}
// getSessionActivityForAssignee looks up tmux session activity for an assignee.
// Assignee format: "rigname/polecats/polecatname" -> session "gt-rigname-polecatname"
func (f *LiveConvoyFetcher) getSessionActivityForAssignee(assignee string) *time.Time {
// Parse assignee: "roxas/polecats/dag" -> rig="roxas", polecat="dag"
parts := strings.Split(assignee, "/")
if len(parts) != 3 || parts[1] != "polecats" {
return nil
}
rig := parts[0]
polecat := parts[2]
// Construct session name
sessionName := fmt.Sprintf("gt-%s-%s", rig, polecat)
// Query tmux for session activity
// Format: session_activity returns unix timestamp
cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}|#{session_activity}",
"-f", fmt.Sprintf("#{==:#{session_name},%s}", sessionName))
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return nil
}
output := strings.TrimSpace(stdout.String())
if output == "" {
return nil
}
// Parse output: "gt-roxas-dag|1704312345"
outputParts := strings.Split(output, "|")
if len(outputParts) < 2 {
return nil
}
var activityUnix int64
if _, err := fmt.Sscanf(outputParts[1], "%d", &activityUnix); err != nil || activityUnix == 0 {
return nil
}
activity := time.Unix(activityUnix, 0)
return &activity
}
// getAllPolecatActivity returns the most recent activity from any running polecat session.
// This is used as a fallback when no specific assignee activity can be determined.
// Returns nil if no polecat sessions are running.
func (f *LiveConvoyFetcher) getAllPolecatActivity() *time.Time {
// List all tmux sessions matching gt-*-* pattern (polecat sessions)
// Format: gt-{rig}-{polecat}
cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}|#{session_activity}")
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return nil
}
var mostRecent time.Time
for _, line := range strings.Split(stdout.String(), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Split(line, "|")
if len(parts) < 2 {
continue
}
sessionName := parts[0]
// Check if it's a polecat session (gt-{rig}-{polecat}, not gt-{rig}-witness/refinery)
// Polecat sessions have exactly 3 parts when split by "-" and the middle part is the rig
nameParts := strings.Split(sessionName, "-")
if len(nameParts) < 3 || nameParts[0] != "gt" {
continue
}
// Skip witness, refinery, mayor, deacon sessions
lastPart := nameParts[len(nameParts)-1]
if lastPart == "witness" || lastPart == "refinery" || lastPart == "mayor" || lastPart == "deacon" {
continue
}
var activityUnix int64
if _, err := fmt.Sscanf(parts[1], "%d", &activityUnix); err != nil || activityUnix == 0 {
continue
}
activityTime := time.Unix(activityUnix, 0)
if activityTime.After(mostRecent) {
mostRecent = activityTime
}
}
if mostRecent.IsZero() {
return nil
}
return &mostRecent
}
// calculateWorkStatus determines the work status based on progress and activity.
// Returns: "complete", "active", "stale", "stuck", or "waiting"
func calculateWorkStatus(completed, total int, activityColor string) string {
// Check if all work is done
if total > 0 && completed == total {
return "complete"
}
// Determine status based on activity color
switch activityColor {
case activity.ColorGreen:
return "active"
case activity.ColorYellow:
return "stale"
case activity.ColorRed:
return "stuck"
default:
return "waiting"
}
}
// FetchMergeQueue fetches open PRs from configured repos.
func (f *LiveConvoyFetcher) FetchMergeQueue() ([]MergeQueueRow, error) {
// Repos to query for PRs
repos := []struct {
Full string // Full repo path for gh CLI
Short string // Short name for display
}{
{"michaellady/roxas", "roxas"},
{"michaellady/gastown", "gastown"},
}
var result []MergeQueueRow
for _, repo := range repos {
prs, err := f.fetchPRsForRepo(repo.Full, repo.Short)
if err != nil {
// Non-fatal: continue with other repos
continue
}
result = append(result, prs...)
}
return result, nil
}
// prResponse represents the JSON response from gh pr list.
type prResponse struct {
Number int `json:"number"`
Title string `json:"title"`
URL string `json:"url"`
Mergeable string `json:"mergeable"`
StatusCheckRollup []struct {
State string `json:"state"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
} `json:"statusCheckRollup"`
}
// fetchPRsForRepo fetches open PRs for a single repo.
func (f *LiveConvoyFetcher) fetchPRsForRepo(repoFull, repoShort string) ([]MergeQueueRow, error) {
// #nosec G204 -- gh is a trusted CLI, repo is from hardcoded list
cmd := exec.Command("gh", "pr", "list",
"--repo", repoFull,
"--state", "open",
"--json", "number,title,url,mergeable,statusCheckRollup")
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("fetching PRs for %s: %w", repoFull, err)
}
var prs []prResponse
if err := json.Unmarshal(stdout.Bytes(), &prs); err != nil {
return nil, fmt.Errorf("parsing PRs for %s: %w", repoFull, err)
}
result := make([]MergeQueueRow, 0, len(prs))
for _, pr := range prs {
row := MergeQueueRow{
Number: pr.Number,
Repo: repoShort,
Title: pr.Title,
URL: pr.URL,
}
// Determine CI status from statusCheckRollup
row.CIStatus = determineCIStatus(pr.StatusCheckRollup)
// Determine mergeable status
row.Mergeable = determineMergeableStatus(pr.Mergeable)
// Determine color class based on overall status
row.ColorClass = determineColorClass(row.CIStatus, row.Mergeable)
result = append(result, row)
}
return result, nil
}
// determineCIStatus evaluates the overall CI status from status checks.
func determineCIStatus(checks []struct {
State string `json:"state"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
}) string {
if len(checks) == 0 {
return "pending"
}
hasFailure := false
hasPending := false
for _, check := range checks {
// Check conclusion first (for completed checks)
switch check.Conclusion {
case "failure", "cancelled", "timed_out", "action_required": //nolint:misspell // GitHub API returns "cancelled" (British spelling)
hasFailure = true
case "success", "skipped", "neutral":
// Pass
default:
// Check status for in-progress checks
switch check.Status {
case "queued", "in_progress", "waiting", "pending", "requested":
hasPending = true
}
// Also check state field
switch check.State {
case "FAILURE", "ERROR":
hasFailure = true
case "PENDING", "EXPECTED":
hasPending = true
}
}
}
if hasFailure {
return "fail"
}
if hasPending {
return "pending"
}
return "pass"
}
// determineMergeableStatus converts GitHub's mergeable field to display value.
func determineMergeableStatus(mergeable string) string {
switch strings.ToUpper(mergeable) {
case "MERGEABLE":
return "ready"
case "CONFLICTING":
return "conflict"
default:
return "pending"
}
}
// determineColorClass determines the row color based on CI and merge status.
func determineColorClass(ciStatus, mergeable string) string {
if ciStatus == "fail" || mergeable == "conflict" {
return "mq-red"
}
if ciStatus == "pending" || mergeable == "pending" {
return "mq-yellow"
}
if ciStatus == "pass" && mergeable == "ready" {
return "mq-green"
}
return "mq-yellow"
}
// FetchPolecats fetches all running polecat and refinery sessions with activity data.
func (f *LiveConvoyFetcher) FetchPolecats() ([]PolecatRow, error) {
// Query all tmux sessions with window_activity for more accurate timing
cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}|#{window_activity}")
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
// tmux not running or no sessions
return nil, nil
}
// Pre-fetch merge queue count to determine refinery idle status
mergeQueueCount := f.getMergeQueueCount()
var polecats []PolecatRow
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
for _, line := range lines {
if line == "" {
continue
}
parts := strings.Split(line, "|")
if len(parts) < 2 {
continue
}
sessionName := parts[0]
// Filter for gt-<rig>-<polecat> pattern
if !strings.HasPrefix(sessionName, "gt-") {
continue
}
// Parse session name: gt-roxas-dag -> rig=roxas, polecat=dag
nameParts := strings.SplitN(sessionName, "-", 3)
if len(nameParts) != 3 {
continue
}
rig := nameParts[1]
polecat := nameParts[2]
// Skip non-worker sessions (witness, mayor, deacon, boot)
// Note: refinery is included to show idle/processing status
if polecat == "witness" || polecat == "mayor" || polecat == "deacon" || polecat == "boot" {
continue
}
// Parse activity timestamp
var activityUnix int64
if _, err := fmt.Sscanf(parts[1], "%d", &activityUnix); err != nil || activityUnix == 0 {
continue
}
activityTime := time.Unix(activityUnix, 0)
// Get status hint - special handling for refinery
var statusHint string
if polecat == "refinery" {
statusHint = f.getRefineryStatusHint(mergeQueueCount)
} else {
statusHint = f.getPolecatStatusHint(sessionName)
}
polecats = append(polecats, PolecatRow{
Name: polecat,
Rig: rig,
SessionID: sessionName,
LastActivity: activity.Calculate(activityTime),
StatusHint: statusHint,
})
}
return polecats, nil
}
// getPolecatStatusHint captures the last non-empty line from a polecat's pane.
func (f *LiveConvoyFetcher) getPolecatStatusHint(sessionName string) string {
cmd := exec.Command("tmux", "capture-pane", "-t", sessionName, "-p", "-J")
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return ""
}
// Get last non-empty line
lines := strings.Split(stdout.String(), "\n")
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if line != "" {
// Truncate long lines
if len(line) > 60 {
line = line[:57] + "..."
}
return line
}
}
return ""
}
// getMergeQueueCount returns the total number of open PRs across all repos.
func (f *LiveConvoyFetcher) getMergeQueueCount() int {
mergeQueue, err := f.FetchMergeQueue()
if err != nil {
return 0
}
return len(mergeQueue)
}
// getRefineryStatusHint returns appropriate status for refinery based on merge queue.
func (f *LiveConvoyFetcher) getRefineryStatusHint(mergeQueueCount int) string {
if mergeQueueCount == 0 {
return "Idle - Waiting for PRs"
}
if mergeQueueCount == 1 {
return "Processing 1 PR"
}
return fmt.Sprintf("Processing %d PRs", mergeQueueCount)
}
// truncateStatusHint truncates a status hint to 60 characters with ellipsis.
func truncateStatusHint(line string) string {
if len(line) > 60 {
return line[:57] + "..."
}
return line
}
// parsePolecatSessionName parses a tmux session name into rig and polecat components.
// Format: gt-<rig>-<polecat> -> (rig, polecat, true)
// Returns ("", "", false) if the format is invalid.
func parsePolecatSessionName(sessionName string) (rig, polecat string, ok bool) {
if !strings.HasPrefix(sessionName, "gt-") {
return "", "", false
}
parts := strings.SplitN(sessionName, "-", 3)
if len(parts) != 3 {
return "", "", false
}
return parts[1], parts[2], true
}
// isWorkerSession returns true if the polecat name represents a worker session.
// Non-worker sessions: witness, mayor, deacon, boot
func isWorkerSession(polecat string) bool {
switch polecat {
case "witness", "mayor", "deacon", "boot":
return false
default:
return true
}
}
// parseActivityTimestamp parses a Unix timestamp string from tmux.
// Returns (0, false) for invalid or zero timestamps.
func parseActivityTimestamp(s string) (int64, bool) {
var unix int64
if _, err := fmt.Sscanf(s, "%d", &unix); err != nil || unix <= 0 {
return 0, false
}
return unix, true
}

View File

@@ -0,0 +1,417 @@
package web
import (
"testing"
"github.com/steveyegge/gastown/internal/activity"
)
func TestCalculateWorkStatus(t *testing.T) {
tests := []struct {
name string
completed int
total int
activityColor string
want string
}{
{
name: "complete when all done",
completed: 5,
total: 5,
activityColor: activity.ColorGreen,
want: "complete",
},
{
name: "complete overrides activity color",
completed: 3,
total: 3,
activityColor: activity.ColorRed,
want: "complete",
},
{
name: "active when green",
completed: 2,
total: 5,
activityColor: activity.ColorGreen,
want: "active",
},
{
name: "stale when yellow",
completed: 2,
total: 5,
activityColor: activity.ColorYellow,
want: "stale",
},
{
name: "stuck when red",
completed: 2,
total: 5,
activityColor: activity.ColorRed,
want: "stuck",
},
{
name: "waiting when unknown color",
completed: 2,
total: 5,
activityColor: activity.ColorUnknown,
want: "waiting",
},
{
name: "waiting when empty color",
completed: 0,
total: 5,
activityColor: "",
want: "waiting",
},
{
name: "waiting when no work yet",
completed: 0,
total: 0,
activityColor: activity.ColorUnknown,
want: "waiting",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := calculateWorkStatus(tt.completed, tt.total, tt.activityColor)
if got != tt.want {
t.Errorf("calculateWorkStatus(%d, %d, %q) = %q, want %q",
tt.completed, tt.total, tt.activityColor, got, tt.want)
}
})
}
}
func TestDetermineCIStatus(t *testing.T) {
tests := []struct {
name string
checks []struct {
State string `json:"state"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
}
want string
}{
{
name: "pending when no checks",
checks: nil,
want: "pending",
},
{
name: "pass when all success",
checks: []struct {
State string `json:"state"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
}{
{Conclusion: "success"},
{Conclusion: "success"},
},
want: "pass",
},
{
name: "pass with skipped checks",
checks: []struct {
State string `json:"state"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
}{
{Conclusion: "success"},
{Conclusion: "skipped"},
},
want: "pass",
},
{
name: "fail when any failure",
checks: []struct {
State string `json:"state"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
}{
{Conclusion: "success"},
{Conclusion: "failure"},
},
want: "fail",
},
{
name: "fail when cancelled",
checks: []struct {
State string `json:"state"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
}{
{Conclusion: "cancelled"},
},
want: "fail",
},
{
name: "fail when timed_out",
checks: []struct {
State string `json:"state"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
}{
{Conclusion: "timed_out"},
},
want: "fail",
},
{
name: "pending when in_progress",
checks: []struct {
State string `json:"state"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
}{
{Conclusion: "success"},
{Status: "in_progress"},
},
want: "pending",
},
{
name: "pending when queued",
checks: []struct {
State string `json:"state"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
}{
{Status: "queued"},
},
want: "pending",
},
{
name: "fail from state FAILURE",
checks: []struct {
State string `json:"state"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
}{
{State: "FAILURE"},
},
want: "fail",
},
{
name: "pending from state PENDING",
checks: []struct {
State string `json:"state"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
}{
{State: "PENDING"},
},
want: "pending",
},
{
name: "failure takes precedence over pending",
checks: []struct {
State string `json:"state"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
}{
{Conclusion: "failure"},
{Status: "in_progress"},
},
want: "fail",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := determineCIStatus(tt.checks)
if got != tt.want {
t.Errorf("determineCIStatus() = %q, want %q", got, tt.want)
}
})
}
}
func TestDetermineMergeableStatus(t *testing.T) {
tests := []struct {
name string
mergeable string
want string
}{
{"ready when MERGEABLE", "MERGEABLE", "ready"},
{"ready when lowercase mergeable", "mergeable", "ready"},
{"conflict when CONFLICTING", "CONFLICTING", "conflict"},
{"conflict when lowercase conflicting", "conflicting", "conflict"},
{"pending when UNKNOWN", "UNKNOWN", "pending"},
{"pending when empty", "", "pending"},
{"pending when other value", "something_else", "pending"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := determineMergeableStatus(tt.mergeable)
if got != tt.want {
t.Errorf("determineMergeableStatus(%q) = %q, want %q",
tt.mergeable, got, tt.want)
}
})
}
}
func TestDetermineColorClass(t *testing.T) {
tests := []struct {
name string
ciStatus string
mergeable string
want string
}{
{"green when pass and ready", "pass", "ready", "mq-green"},
{"red when CI fails", "fail", "ready", "mq-red"},
{"red when conflict", "pass", "conflict", "mq-red"},
{"red when both fail and conflict", "fail", "conflict", "mq-red"},
{"yellow when CI pending", "pending", "ready", "mq-yellow"},
{"yellow when merge pending", "pass", "pending", "mq-yellow"},
{"yellow when both pending", "pending", "pending", "mq-yellow"},
{"yellow for unknown states", "unknown", "unknown", "mq-yellow"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := determineColorClass(tt.ciStatus, tt.mergeable)
if got != tt.want {
t.Errorf("determineColorClass(%q, %q) = %q, want %q",
tt.ciStatus, tt.mergeable, got, tt.want)
}
})
}
}
func TestGetRefineryStatusHint(t *testing.T) {
// Create a minimal fetcher for testing
f := &LiveConvoyFetcher{}
tests := []struct {
name string
mergeQueueCount int
want string
}{
{"idle when no PRs", 0, "Idle - Waiting for PRs"},
{"singular PR", 1, "Processing 1 PR"},
{"multiple PRs", 2, "Processing 2 PRs"},
{"many PRs", 10, "Processing 10 PRs"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := f.getRefineryStatusHint(tt.mergeQueueCount)
if got != tt.want {
t.Errorf("getRefineryStatusHint(%d) = %q, want %q",
tt.mergeQueueCount, got, tt.want)
}
})
}
}
func TestTruncateStatusHint(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"short line unchanged", "Hello world", "Hello world"},
{"exactly 60 chars unchanged", "123456789012345678901234567890123456789012345678901234567890", "123456789012345678901234567890123456789012345678901234567890"},
{"61 chars truncated", "1234567890123456789012345678901234567890123456789012345678901", "123456789012345678901234567890123456789012345678901234567..."},
{"long line truncated", "This is a very long line that should be truncated because it exceeds sixty characters", "This is a very long line that should be truncated because..."},
{"empty string", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := truncateStatusHint(tt.input)
if got != tt.want {
t.Errorf("truncateStatusHint(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestParsePolecatSessionName(t *testing.T) {
tests := []struct {
name string
sessionName string
wantRig string
wantPolecat string
wantOk bool
}{
{"valid polecat session", "gt-roxas-dag", "roxas", "dag", true},
{"valid polecat with hyphen", "gt-gas-town-nux", "gas", "town-nux", true},
{"refinery session", "gt-roxas-refinery", "roxas", "refinery", true},
{"witness session", "gt-gastown-witness", "gastown", "witness", true},
{"not gt prefix", "other-roxas-dag", "", "", false},
{"too few parts", "gt-roxas", "", "", false},
{"empty string", "", "", "", false},
{"single gt", "gt", "", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rig, polecat, ok := parsePolecatSessionName(tt.sessionName)
if ok != tt.wantOk {
t.Errorf("parsePolecatSessionName(%q) ok = %v, want %v",
tt.sessionName, ok, tt.wantOk)
}
if ok && (rig != tt.wantRig || polecat != tt.wantPolecat) {
t.Errorf("parsePolecatSessionName(%q) = (%q, %q), want (%q, %q)",
tt.sessionName, rig, polecat, tt.wantRig, tt.wantPolecat)
}
})
}
}
func TestIsWorkerSession(t *testing.T) {
tests := []struct {
name string
polecat string
wantWork bool
}{
{"polecat dag is worker", "dag", true},
{"polecat nux is worker", "nux", true},
{"refinery is worker", "refinery", true},
{"witness is not worker", "witness", false},
{"mayor is not worker", "mayor", false},
{"deacon is not worker", "deacon", false},
{"boot is not worker", "boot", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isWorkerSession(tt.polecat)
if got != tt.wantWork {
t.Errorf("isWorkerSession(%q) = %v, want %v",
tt.polecat, got, tt.wantWork)
}
})
}
}
func TestParseActivityTimestamp(t *testing.T) {
tests := []struct {
name string
input string
wantUnix int64
wantValid bool
}{
{"valid timestamp", "1704312345", 1704312345, true},
{"zero timestamp", "0", 0, false},
{"empty string", "", 0, false},
{"invalid string", "abc", 0, false},
{"negative", "-123", 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
unix, valid := parseActivityTimestamp(tt.input)
if valid != tt.wantValid {
t.Errorf("parseActivityTimestamp(%q) valid = %v, want %v",
tt.input, valid, tt.wantValid)
}
if valid && unix != tt.wantUnix {
t.Errorf("parseActivityTimestamp(%q) = %d, want %d",
tt.input, unix, tt.wantUnix)
}
})
}
}

View File

@@ -8,6 +8,8 @@ import (
// ConvoyFetcher defines the interface for fetching convoy data.
type ConvoyFetcher interface {
FetchConvoys() ([]ConvoyRow, error)
FetchMergeQueue() ([]MergeQueueRow, error)
FetchPolecats() ([]PolecatRow, error)
}
// ConvoyHandler handles HTTP requests for the convoy dashboard.
@@ -37,8 +39,22 @@ func (h *ConvoyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
mergeQueue, err := h.fetcher.FetchMergeQueue()
if err != nil {
// Non-fatal: show convoys even if merge queue fails
mergeQueue = nil
}
polecats, err := h.fetcher.FetchPolecats()
if err != nil {
// Non-fatal: show convoys even if polecats fail
polecats = nil
}
data := ConvoyData{
Convoys: convoys,
Convoys: convoys,
MergeQueue: mergeQueue,
Polecats: polecats,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")

View File

@@ -1,6 +1,8 @@
package web
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -10,16 +12,29 @@ import (
"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
Error error
Convoys []ConvoyRow
MergeQueue []MergeQueueRow
Polecats []PolecatRow
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 TestConvoyHandler_RendersTemplate(t *testing.T) {
mock := &MockConvoyFetcher{
Convoys: []ConvoyRow{
@@ -179,3 +194,804 @@ func TestConvoyHandler_MultipleConvoys(t *testing.T) {
}
}
}
// Integration tests for error handling
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)
if w.Code != http.StatusInternalServerError {
t.Errorf("Status = %d, want %d", w.Code, http.StatusInternalServerError)
}
body := w.Body.String()
if !strings.Contains(body, "Failed to fetch convoys") {
t.Error("Response should contain error message")
}
}
// 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, "Refinery 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
if !strings.Contains(body, "ci-pass") {
t.Error("Response should contain ci-pass class for passing PR")
}
if !strings.Contains(body, "ci-pending") {
t.Error("Response should contain ci-pending class 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, "Polecat Workers") {
t.Error("Response should contain polecat workers 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'")
}
// Check status hints
if !strings.Contains(body, "Running tests...") {
t.Error("Response should contain status hint")
}
// 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", "work-complete", "complete"},
{"active status", "active", "work-active", "active"},
{"stale status", "stale", "work-stale", "stale"},
{"stuck status", "stuck", "work-stuck", "stuck"},
{"waiting status", "waiting", "work-waiting", "waiting"},
}
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, "Gas Town Convoys") {
t.Error("Response should contain main header")
}
if !strings.Contains(body, "hq-cv-full") {
t.Error("Response should contain convoy data")
}
if !strings.Contains(body, "Refinery 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, "Polecat Workers") {
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 header", "Gas Town Convoys"},
{"Convoy ID", "hq-cv-e2e"},
{"Convoy title", "E2E Test Convoy"},
{"Convoy progress", "2/4"},
{"Merge queue section", "Refinery Merge Queue"},
{"PR number", "#101"},
{"PR repo", "roxas"},
{"Polecat section", "Polecat Workers"},
{"Polecat name", "furiosa"},
{"Polecat status", "Running E2E tests"},
{"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, "Refinery 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", "merge-ready"},
{"red when CI fails", "fail", "ready", "mq-red", "ci-fail", "merge-ready"},
{"red when conflict", "pass", "conflict", "mq-red", "ci-pass", "merge-conflict"},
{"yellow when pending", "pending", "pending", "mq-yellow", "ci-pending", "merge-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 class %q", tt.wantCI)
}
if !strings.Contains(body, tt.wantMerge) {
t.Errorf("Should contain merge class %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")
}
if !strings.Contains(body, "Idle - Waiting for PRs") {
t.Error("Refinery idle status should be shown")
}
// 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 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")
}
}

View File

@@ -14,14 +14,37 @@ var templateFS embed.FS
// ConvoyData represents data passed to the convoy template.
type ConvoyData struct {
Convoys []ConvoyRow
Convoys []ConvoyRow
MergeQueue []MergeQueueRow
Polecats []PolecatRow
}
// PolecatRow represents a polecat worker in the dashboard.
type PolecatRow struct {
Name string // e.g., "dag", "nux"
Rig string // e.g., "roxas", "gastown"
SessionID string // e.g., "gt-roxas-dag"
LastActivity activity.Info // Colored activity display
StatusHint string // Last line from pane (optional)
}
// MergeQueueRow represents a PR in the merge queue.
type MergeQueueRow struct {
Number int
Repo string // Short repo name (e.g., "roxas", "gastown")
Title string
URL string
CIStatus string // "pass", "fail", "pending"
Mergeable string // "ready", "conflict", "pending"
ColorClass string // "mq-green", "mq-yellow", "mq-red"
}
// ConvoyRow represents a single convoy in the dashboard.
type ConvoyRow struct {
ID string
Title string
Status string // "open" or "closed"
Status string // "open" or "closed" (raw beads status)
WorkStatus string // Computed: "complete", "active", "stale", "stuck", "waiting"
Progress string // e.g., "2/5"
Completed int
Total int
@@ -43,6 +66,7 @@ func LoadTemplates() (*template.Template, error) {
funcMap := template.FuncMap{
"activityClass": activityClass,
"statusClass": statusClass,
"workStatusClass": workStatusClass,
"progressPercent": progressPercent,
}
@@ -87,6 +111,24 @@ func statusClass(status string) string {
}
}
// workStatusClass returns the CSS class for a computed work status.
func workStatusClass(workStatus string) string {
switch workStatus {
case "complete":
return "work-complete"
case "active":
return "work-active"
case "stale":
return "work-stale"
case "stuck":
return "work-stuck"
case "waiting":
return "work-waiting"
default:
return "work-unknown"
}
}
// progressPercent calculates percentage as an integer for progress bars.
func progressPercent(completed, total int) int {
if total == 0 {

View File

@@ -104,6 +104,41 @@
background: var(--green);
}
/* Work status badges */
.work-status {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.work-complete .work-status {
background: var(--green);
color: var(--bg-dark);
}
.work-active .work-status {
background: var(--green);
color: var(--bg-dark);
}
.work-stale .work-status {
background: var(--yellow);
color: var(--bg-dark);
}
.work-stuck .work-status {
background: var(--red);
color: var(--bg-dark);
}
.work-waiting .work-status {
background: var(--text-secondary);
color: var(--bg-dark);
}
/* Activity colors */
.activity-dot {
display: inline-block;
@@ -176,6 +211,106 @@
font-size: 0.875rem;
}
.empty-state-inline {
text-align: center;
padding: 24px;
color: var(--text-secondary);
background: var(--bg-card);
border-radius: 8px;
}
.empty-state-inline p {
font-size: 0.875rem;
margin: 0;
}
.status-hint {
color: var(--text-secondary);
font-size: 0.875rem;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.section-header {
margin-top: 32px;
margin-bottom: 16px;
font-size: 1.25rem;
font-weight: 600;
}
/* Merge queue colors */
.mq-green {
background: rgba(74, 222, 128, 0.1);
}
.mq-yellow {
background: rgba(250, 204, 21, 0.1);
}
.mq-red {
background: rgba(248, 113, 113, 0.1);
}
.ci-status, .merge-status {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.ci-pass {
background: var(--green);
color: var(--bg-dark);
}
.ci-fail {
background: var(--red);
color: var(--bg-dark);
}
.ci-pending {
background: var(--yellow);
color: var(--bg-dark);
}
.merge-ready {
background: var(--green);
color: var(--bg-dark);
}
.merge-conflict {
background: var(--red);
color: var(--bg-dark);
}
.merge-pending {
background: var(--yellow);
color: var(--bg-dark);
}
.pr-link {
color: var(--text-primary);
text-decoration: none;
}
.pr-link:hover {
text-decoration: underline;
}
.pr-title {
color: var(--text-secondary);
margin-left: 8px;
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
vertical-align: middle;
}
/* htmx loading indicator */
.htmx-request .htmx-indicator {
opacity: 1;
@@ -188,11 +323,11 @@
</style>
</head>
<body>
<div class="dashboard" hx-get="/" hx-trigger="every 30s" hx-swap="outerHTML">
<div class="dashboard" hx-get="/" hx-trigger="every 10s" hx-swap="outerHTML">
<header>
<h1>🚚 Gas Town Convoys</h1>
<span class="refresh-info">
Auto-refresh: every 30s
Auto-refresh: every 10s
<span class="htmx-indicator"></span>
</span>
</header>
@@ -209,10 +344,9 @@
</thead>
<tbody>
{{range .Convoys}}
<tr class="{{statusClass .Status}}">
<tr class="{{workStatusClass .WorkStatus}}">
<td>
<span class="status-indicator"></span>
{{if eq .Status "open"}}●{{else}}✓{{end}}
<span class="work-status">{{.WorkStatus}}</span>
</td>
<td>
<span class="convoy-id">{{.ID}}</span>
@@ -240,6 +374,85 @@
<p>Create a convoy with: gt convoy create &lt;name&gt; [issues...]</p>
</div>
{{end}}
<h2 class="section-header">🔀 Refinery Merge Queue</h2>
{{if .MergeQueue}}
<table class="convoy-table">
<thead>
<tr>
<th>PR #</th>
<th>Repo</th>
<th>Title</th>
<th>CI Status</th>
<th>Mergeable</th>
</tr>
</thead>
<tbody>
{{range .MergeQueue}}
<tr class="{{.ColorClass}}">
<td>
<a href="{{.URL}}" target="_blank" class="pr-link">#{{.Number}}</a>
</td>
<td>{{.Repo}}</td>
<td>
<span class="pr-title">{{.Title}}</span>
</td>
<td>
{{if eq .CIStatus "pass"}}
<span class="ci-status ci-pass">✓ Pass</span>
{{else if eq .CIStatus "fail"}}
<span class="ci-status ci-fail">✗ Fail</span>
{{else}}
<span class="ci-status ci-pending">⏳ Pending</span>
{{end}}
</td>
<td>
{{if eq .Mergeable "ready"}}
<span class="merge-status merge-ready">Ready</span>
{{else if eq .Mergeable "conflict"}}
<span class="merge-status merge-conflict">Conflict</span>
{{else}}
<span class="merge-status merge-pending">Pending</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state-inline">
<p>No PRs in queue</p>
</div>
{{end}}
{{if .Polecats}}
<h2 class="section-header">🐾 Polecat Workers</h2>
<table class="convoy-table">
<thead>
<tr>
<th>Polecat</th>
<th>Rig</th>
<th>Last Activity</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{{range .Polecats}}
<tr>
<td>
<span class="convoy-id">{{.Name}}</span>
</td>
<td>{{.Rig}}</td>
<td class="{{activityClass .LastActivity}}">
<span class="activity-dot"></span>
{{.LastActivity.FormattedAge}}
</td>
<td class="status-hint">{{.StatusHint}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
</body>
</html>

View File

@@ -137,8 +137,8 @@ func TestConvoyTemplate_HtmxAutoRefresh(t *testing.T) {
if !strings.Contains(output, "hx-trigger") {
t.Error("Template should contain hx-trigger for auto-refresh")
}
if !strings.Contains(output, "every 30s") {
t.Error("Template should refresh every 30 seconds")
if !strings.Contains(output, "every 10s") {
t.Error("Template should refresh every 10 seconds")
}
}