Files
homarr/tools/homarr-dev/internal/tui/harness_test.go
T

271 lines
9.7 KiB
Go

package tui
import (
"context"
"fmt"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/task"
)
func sample() (Model, tea.Cmd) {
m := New(false)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 140, Height: 40})
m = updated.(Model)
loaded := loadedMsg{
prs: []gh.PR{
{Number: 6646, Title: "feat: rework the developer CLI from the ground up", Author: "ajnart", HeadRef: "excited-pelican", HeadSHA: "abc123", CIState: "SUCCESS"},
{Number: 6612, Title: "fix(board): keep the item modal aligned", Author: "meierschlurf", HeadRef: "fix/modal", HeadSHA: "def456", CIState: "PENDING"},
{Number: 6503, Title: "chore(deps): bump everything", Author: "renovate[bot]", HeadRef: "renovate/all", HeadSHA: "ghi789", CIState: "FAILURE", IsDraft: true},
},
images: []docker.Image{
{ID: "1", Tag: "pr-6646", PRNumber: 6646, Revision: "abc123", Size: "492MB", Created: "33 minutes ago", Source: "https://github.com/homarr-labs/homarr/pull/6646"},
{ID: "2", Tag: "dev", Revision: "zzz999", Size: "481MB", Created: "2 days ago", Source: "/Users/ajnart/homarr"},
},
containers: []docker.Container{
{ID: "c1", Name: "homarr_pr_6612", Image: "ghcr.io/homarr-labs/homarr-test:pr-6612", State: "running", Status: "Up 45 hours (healthy)", Ports: "0.0.0.0:7576->7575/tcp"},
{ID: "c2", Name: "homarr_dev", Image: "homarr:dev", State: "exited", Status: "Exited (0) 3 weeks ago"},
},
volumes: []docker.Volume{{Name: "homarr_pr_6612_data", Size: "6.8MB"}, {Name: "homarr_dev_data", Size: "12MB"}},
tags: map[string]bool{"pr-6612": true, "pr-6503": true},
}
next, cmd := m.Update(loaded)
return next.(Model), cmd
}
func TestRenderDev(t *testing.T) {
m, _ := sample()
fmt.Println("=========== DEV ===========")
fmt.Println(m.View().Content)
}
func TestRenderInstances(t *testing.T) {
m, _ := sample()
next, _ := m.switchScreen(screenInstances)
fmt.Println("=========== INSTANCES ===========")
fmt.Println(next.View().Content)
}
func TestDebugSelection(t *testing.T) {
m, _ := sample()
t.Logf("devFiltered=%d cursor=%d rows=%d", len(m.devFiltered), m.devTable.Cursor(), len(m.devTable.Rows()))
row, found := m.selectedDev()
t.Logf("selectedDev found=%v key=%q", found, row.key())
t.Logf("detailVisible=%v", m.detailVisible())
t.Logf("detail=%q", m.detail())
t.Logf("bodyHeight=%d sidebarCols=%d height=%d", m.bodyHeight, m.sidebarCols, m.height)
}
func fakePullTask(m Model) Model {
m.tasks.Start(task.KindPull, "pull and start PR #6612", "homarr_pr_6612", func(ctx context.Context, report *task.Reporter) error {
report.SetDetail("4/6 layers · 128.4MB/492.1MB · 12.4MB/s")
report.SetPercent(0.58)
report.SetSteps([]task.Step{
{ID: "a1b2c3d4e5f6", Label: "a1b2c3d4e5f6", Status: "Pull complete", Percent: 1, Done: true, Note: "48.2MB"},
{ID: "b2c3d4e5f6a7", Label: "b2c3d4e5f6a7", Status: "Already exists", Percent: 1, Done: true, Note: "12.1MB"},
{ID: "c3d4e5f6a7b8", Label: "c3d4e5f6a7b8", Status: "Extracting", Percent: 0.72, Note: "88.4MB/122.0MB"},
{ID: "d4e5f6a7b8c9", Label: "d4e5f6a7b8c9", Status: "Downloading", Percent: 0.21, Note: "18.2MB/86.5MB"},
{ID: "e5f6a7b8c9d0", Label: "e5f6a7b8c9d0", Status: "Waiting", Percent: 0},
})
<-ctx.Done()
return ctx.Err()
})
time.Sleep(60 * time.Millisecond)
next, _ := m.applyTasks()
next.focusTask = 1
next.sidebar.source = sourceTask
next.relayout()
next.refreshSidebar()
return next
}
func TestRenderPullProgress(t *testing.T) {
m, _ := sample()
m = fakePullTask(m)
fmt.Println("=========== PULL ===========")
fmt.Println(m.View().Content)
m.tasks.CancelAll()
}
func TestRenderManageAndConfirm(t *testing.T) {
m, _ := sample()
updated, _ := m.handleKey(tea.KeyPressMsg{Code: 'm', Text: "m"})
manage := updated.(Model)
fmt.Println("=========== MANAGE ===========")
fmt.Println(manage.View().Content)
updated, _ = manage.handleKey(tea.KeyPressMsg{Code: 'd', Text: "d"})
fmt.Println("=========== CONFIRM ===========")
fmt.Println(updated.(Model).View().Content)
}
func TestRenderTaskOverlayAndHelp(t *testing.T) {
m, _ := sample()
m = fakePullTask(m)
updated, _ := m.handleKey(tea.KeyPressMsg{Code: 't', Text: "t"})
fmt.Println("=========== TASKS ===========")
fmt.Println(updated.(Model).View().Content)
m.tasks.CancelAll()
m2, _ := sample()
updated, _ = m2.handleKey(tea.KeyPressMsg{Code: '?', Text: "?"})
fmt.Println("=========== HELP ===========")
fmt.Println(updated.(Model).View().Content)
}
func TestRenderNarrow(t *testing.T) {
m, _ := sample()
updated, _ := m.Update(tea.WindowSizeMsg{Width: 84, Height: 30})
fmt.Println("=========== NARROW (stacked sidebar) ===========")
fmt.Println(updated.(Model).View().Content)
}
func TestSidebarTaskAutoSwitchToLogsOnSuccess(t *testing.T) {
m, _ := sample()
started := m.tasks.Start(task.KindPull, "pull PR #6612", "homarr_pr_6612", func(ctx context.Context, report *task.Reporter) error {
return nil
})
next, _ := m.focusOn(started.ID(), "pull PR #6612")
if next.sidebar.source != sourceTask {
t.Fatalf("expected sidebar source to be sourceTask while task is focused, got %v", next.sidebar.source)
}
time.Sleep(50 * time.Millisecond)
next, _ = next.applyTasks()
if next.sidebar.source != sourceLogs {
t.Fatalf("expected sidebar source to auto-switch to sourceLogs on task success, got %v", next.sidebar.source)
}
if next.focusTask != 0 {
t.Fatalf("expected focusTask to reset to 0 on task success, got %d", next.focusTask)
}
}
func TestSidebarTaskAutoSwitchToLogsOnCancel(t *testing.T) {
m, _ := sample()
started := m.tasks.Start(task.KindBuild, "build homarr:dev", "homarr:dev", func(ctx context.Context, report *task.Reporter) error {
<-ctx.Done()
return ctx.Err()
})
next, _ := m.focusOn(started.ID(), "build homarr:dev")
if next.sidebar.source != sourceTask {
t.Fatalf("expected sidebar source to be sourceTask, got %v", next.sidebar.source)
}
m.tasks.Cancel(started.ID())
time.Sleep(50 * time.Millisecond)
next, _ = next.applyTasks()
if next.sidebar.source != sourceLogs {
t.Fatalf("expected sidebar source to auto-switch to sourceLogs on task cancel, got %v", next.sidebar.source)
}
}
func TestInstanceScreenActionsAndManageChords(t *testing.T) {
m, _ := sample()
next, _ := m.switchScreen(screenInstances)
if next.screen != screenInstances {
t.Fatalf("expected screenInstances, got %v", next.screen)
}
// Test manage mode entry
updated, _ := next.handleKey(tea.KeyPressMsg{Code: 'm', Text: "m"})
manage := updated.(Model)
if manage.mode != modeManage {
t.Fatalf("expected modeManage, got %v", manage.mode)
}
// Press Esc to return to normal mode
updated, _ = manage.handleKey(tea.KeyPressMsg{Code: tea.KeyEscape})
normal := updated.(Model)
if normal.mode != modeNormal {
t.Fatalf("expected modeNormal after Esc, got %v", normal.mode)
}
}
func TestSidebarRelevanceOnNavigationAndPinning(t *testing.T) {
m, _ := sample()
// Row 0 is PR 6646 (not running, no container)
if m.sidebarRelevant() {
t.Fatalf("expected sidebar to NOT be relevant for row without container")
}
// Move down to Row 1 (PR 6612, which is running)
moved, _ := m.moveSelection(1)
if !moved.sidebarRelevant() {
t.Fatalf("expected sidebar to BE relevant for running container")
}
if moved.logTarget() != "homarr_pr_6612" {
t.Fatalf("expected logTarget=homarr_pr_6612, got %q", moved.logTarget())
}
// Move down to Row 2 (PR 6503, no container)
moved2, _ := moved.moveSelection(1)
if moved2.sidebarRelevant() {
t.Fatalf("expected sidebar to NOT be relevant for row without container")
}
// Pin while on Row 1
pinned, _ := moved.togglePin()
if pinned.sidebar.pinned != "homarr_pr_6612" {
t.Fatalf("expected pinned container to be homarr_pr_6612, got %q", pinned.sidebar.pinned)
}
// Move to Row 2 while pinned -> sidebar remains relevant!
movedPinned, _ := pinned.moveSelection(1)
if !movedPinned.sidebarRelevant() {
t.Fatalf("expected sidebar to remain relevant when a valid container is pinned")
}
}
func TestSidebarTabCycleAndCIRendering(t *testing.T) {
m, _ := sample()
// Initial source is sourceLogs
if m.sidebar.source != sourceLogs {
t.Fatalf("expected sourceLogs, got %v", m.sidebar.source)
}
// Press Tab (no active tasks) -> cycles to sourceCI
tabbed, _ := m.handleKey(tea.KeyPressMsg{Code: tea.KeyTab})
mTabbed := tabbed.(Model)
if mTabbed.sidebar.source != sourceCI {
t.Fatalf("expected sourceCI after Tab, got %v", mTabbed.sidebar.source)
}
if !mTabbed.sidebarRelevant() {
t.Fatalf("expected sidebar to be relevant on PR row when in sourceCI mode")
}
// Supply mock CI checks for PR 6646
updated, _ := mTabbed.Update(ciChecksMsg{
pr: 6646,
checks: []gh.Check{
{Name: "Fast gate", Bucket: "pass", State: "SUCCESS", StartedAt: "2026-08-21T10:00:00Z", CompletedAt: "2026-08-21T10:12:00Z"},
{Name: "Build amd64", Bucket: "pass", State: "SUCCESS", StartedAt: "2026-08-21T10:12:00Z", CompletedAt: "2026-08-21T10:17:00Z"},
{Name: "E2E tests", Bucket: "pending", State: "PENDING", StartedAt: "2026-08-21T10:17:00Z"},
{Name: "publish", Bucket: "skipping", State: "SKIPPED"},
},
})
mCI := updated.(Model)
if mCI.sidebar.title != "ci · PR #6646" {
t.Fatalf("unexpected title: %q", mCI.sidebar.title)
}
if !strings.Contains(mCI.sidebar.subtitle, "2 pass") || !strings.Contains(mCI.sidebar.subtitle, "1 pending") || !strings.Contains(mCI.sidebar.subtitle, "1 skipped") {
t.Fatalf("unexpected subtitle: %q", mCI.sidebar.subtitle)
}
// Press Tab again -> cycles back to sourceLogs
tabbedAgain, _ := mCI.handleKey(tea.KeyPressMsg{Code: tea.KeyTab})
mLogs := tabbedAgain.(Model)
if mLogs.sidebar.source != sourceLogs {
t.Fatalf("expected sourceLogs after Tab, got %v", mLogs.sidebar.source)
}
}