+12








fcd7c979df
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com> Co-authored-by: arturict <arturict@users.noreply.github.com> Co-authored-by: Thomas <thomas@ajnart.fr> Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com> Co-authored-by: Danura30082 <danura30082+github@gmail.com> Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com> Co-authored-by: ajnart <thomas@ajnart.fr> Co-authored-by: ajnart <thomas@ajnart.dev> Co-authored-by: chenjun <chenjun@foxmail.com> Co-authored-by: Wikibear <wikibear@users.noreply.github.com> Co-authored-by: Bergasha <bergasha@gmail.com> Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com> Co-authored-by: Lucas Araujo <lucas.lra@gmail.com> Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com> Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social> Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com> Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com> Co-authored-by: thrawnx <tight.bulb9384@fastmail.com> Co-authored-by: Chris <58918656+nordada@users.noreply.github.com> Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com> Co-authored-by: Yanis <naamaneyanis@gmail.com> Co-authored-by: Wikibear <me@sej.de> Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
247 lines
8.8 KiB
Go
247 lines
8.8 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"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 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)
|
|
}
|
|
}
|
|
|
|
func TestDeleteDataVerifiesMountBeforeRemoval(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
state task.State
|
|
calls string
|
|
}{
|
|
{"matching", task.StateSucceeded, "inspect\nrm -f immutable-id\nvolume rm homarr_dev_data\n"},
|
|
{"mismatch", task.StateFailed, "inspect\n"},
|
|
{"inspect-error", task.StateFailed, "inspect\n"},
|
|
{"remove-error", task.StateFailed, "inspect\nrm -f immutable-id\n"},
|
|
{"missing", task.StateSucceeded, "inspect\nvolume rm homarr_dev_data\n"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
log := filepath.Join(dir, "calls")
|
|
script := `#!/bin/sh
|
|
if [ "$1" = container ]; then
|
|
echo inspect >> "$CALL_LOG"
|
|
case "$SCENARIO" in
|
|
mismatch) echo '{"id":"immutable-id","mounts":[{"Name":"other_data"}]}'; exit 0 ;;
|
|
inspect-error) echo 'daemon unavailable' >&2; exit 1 ;;
|
|
missing) echo 'No such container: homarr_dev' >&2; exit 1 ;;
|
|
esac
|
|
echo '{"id":"immutable-id","mounts":[{"Name":"homarr_dev_data"}]}'
|
|
else
|
|
echo "$*" >> "$CALL_LOG"
|
|
if [ "$1" = rm ] && [ "$SCENARIO" = remove-error ]; then
|
|
echo 'removal denied' >&2; exit 1
|
|
fi
|
|
fi
|
|
`
|
|
if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
t.Setenv("CALL_LOG", log)
|
|
t.Setenv("SCENARIO", tc.name)
|
|
m := Model{tasks: task.NewManager()}
|
|
t.Cleanup(m.tasks.CancelAll)
|
|
m.deleteData(target{container: "homarr_dev", volume: "homarr_dev_data"})
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for {
|
|
snapshot := m.tasks.Snapshots()[0]
|
|
if snapshot.State.Done() {
|
|
if snapshot.State != tc.state {
|
|
t.Fatalf("state = %v, want %v: %v", snapshot.State, tc.state, snapshot.Err)
|
|
}
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("delete task did not finish")
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
calls, err := os.ReadFile(log)
|
|
if err != nil || string(calls) != tc.calls {
|
|
t.Fatalf("Docker calls = %q, want %q (read error: %v)", calls, tc.calls, err)
|
|
}
|
|
})
|
|
}
|
|
}
|