diff --git a/.execs/test.flow b/.execs/test.flow index aacddbc3..6df49684 100644 --- a/.execs/test.flow +++ b/.execs/test.flow @@ -59,12 +59,20 @@ executables: echo "Unit tests completed" retries: 3 - - verb: test name: e2e description: Run E2E tests with instrumented binary and coverage serial: dir: // + params: + - envKey: UPDATE_GOLDEN_FILES + text: "false" + - envKey: COLORFGBG + text: 15;0 + - envKey: COLORTERM + text: truecolor + - envKey: TERM + text: xterm-256color execs: - cmd: | set -e diff --git a/go.mod b/go.mod index c3179c43..90285e2d 100644 --- a/go.mod +++ b/go.mod @@ -6,14 +6,16 @@ require ( github.com/Masterminds/sprig/v3 v3.3.0 github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v0.21.0 - github.com/charmbracelet/bubbletea v1.3.5 + github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/exp/teatest v0.0.0-20250711012602-b1f986320f7e github.com/expr-lang/expr v1.17.5 - github.com/flowexec/tuikit v0.2.1 + github.com/flowexec/tuikit v0.2.3 github.com/flowexec/vault v0.1.2 github.com/gen2brain/beeep v0.11.1 github.com/jahvon/glamour v0.8.1-patch3 github.com/mattn/go-runewidth v0.0.16 + github.com/muesli/termenv v0.16.0 github.com/onsi/ginkgo/v2 v2.23.4 github.com/onsi/gomega v1.37.0 github.com/otiai10/copy v1.14.1 @@ -37,13 +39,15 @@ require ( github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymanbagabas/go-udiff v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.3.1 // indirect github.com/charmbracelet/huh v0.7.0 // indirect github.com/charmbracelet/log v0.4.2 // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 // indirect + github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect @@ -73,7 +77,6 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/otiai10/mint v1.6.3 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/go.sum b/go.sum index d38dfcd7..5dc28c75 100644 --- a/go.sum +++ b/go.sum @@ -32,8 +32,8 @@ github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= -github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= -github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc= @@ -42,8 +42,8 @@ github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoF github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 h1:iGrflaL5jQW6crML+pZx/ulWAVZQR3CQoRGvFsr2Tyg= github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81/go.mod h1:poPFOXFTsJsnLbkV3H2KxAAXT7pdjxxLujLocWjkyzM= github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= @@ -54,8 +54,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHE github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= -github.com/charmbracelet/x/exp/teatest v0.0.0-20250623112707-45752038d08d h1:b8GXylLbV6WaBxHjj4fyBqVzWW66vScY5bbJCwoMBOA= -github.com/charmbracelet/x/exp/teatest v0.0.0-20250623112707-45752038d08d/go.mod h1:MhV4atqUTcHvdaA7Qbkgb0Tvvr+BrH6IW7/i2XW39R8= +github.com/charmbracelet/x/exp/teatest v0.0.0-20250711012602-b1f986320f7e h1:DzXKRIGeGAtN+rlLhPr6A2HHYXBmSlJYE/K8WYJib4A= +github.com/charmbracelet/x/exp/teatest v0.0.0-20250711012602-b1f986320f7e/go.mod h1:RXbDhep1qKL/SEz2IuOhOUrsNHDKGqRmGks1nZStKyU= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= @@ -79,8 +79,8 @@ github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJb github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE= github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k= github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= -github.com/flowexec/tuikit v0.2.1 h1:jsW5PrBiem4as3KWmhOB/7OS0LYB0voQnKXvAG0OsPU= -github.com/flowexec/tuikit v0.2.1/go.mod h1:fjMwEM7FkxbP7bIV4CfEjsixgjicgQqPrejoBZAHf5s= +github.com/flowexec/tuikit v0.2.3 h1:hGlBc8yXvj4AXaKFp+IUNQ9nO7xOYY4W99m1BfNT13Q= +github.com/flowexec/tuikit v0.2.3/go.mod h1:fjMwEM7FkxbP7bIV4CfEjsixgjicgQqPrejoBZAHf5s= github.com/flowexec/vault v0.1.2 h1:INQ/w81piKRM+zqPBQpxFYl1iK8dI3APIHZ1F1Jm7CA= github.com/flowexec/vault v0.1.2/go.mod h1:nxoGHIVjwSgg1o6DoTmj5NCJtubu71SvS883LPUXuvg= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= diff --git a/internal/io/library/init.go b/internal/io/library/init.go index bac72962..186ce39e 100644 --- a/internal/io/library/init.go +++ b/internal/io/library/init.go @@ -48,14 +48,18 @@ func (l *Library) setVisibleExecs() { } curWs := l.filter.Workspace + + l.mu.RLock() if label := l.visibleWorkspaces[l.currentWorkspace]; label != "" && label != allWorkspacesLabel { curWs = label } else if curWs == allWorkspacesLabel { curWs = "" } + l.mu.RUnlock() curNs := l.filter.Namespace if l.showNamespaces && len(l.visibleNamespaces) > 0 { + l.mu.RLock() if label := l.visibleNamespaces[l.currentNamespace]; label != "" { switch label { case withoutNamespaceLabel: @@ -66,6 +70,7 @@ func (l *Library) setVisibleExecs() { curNs = label } } + l.mu.RUnlock() } filter := l.filter @@ -80,7 +85,10 @@ func (l *Library) setVisibleExecs() { slices.SortFunc(filteredExec, func(i, j *executable.Executable) int { return strings.Compare(i.Ref().String(), j.Ref().String()) }) + + l.mu.Lock() l.visibleExecutables = filteredExec + l.mu.Unlock() } func (l *Library) setVisibleWorkspaces() { @@ -112,7 +120,10 @@ func (l *Library) setVisibleWorkspaces() { labels = append(labels, ws.AssignedName()) } slices.Sort(labels) + + l.mu.Lock() l.visibleWorkspaces = append(prepend, labels...) //nolint:gocritic + l.mu.Unlock() } func (l *Library) setVisibleNamespaces() { @@ -123,7 +134,11 @@ func (l *Library) setVisibleNamespaces() { var labels, prepend []string var someWithoutNs bool filter := l.filter + + l.mu.RLock() filterWs := l.visibleWorkspaces[l.currentWorkspace] + l.mu.RUnlock() + nsSet := make(map[string]struct{}) for _, ex := range l.allExecutables { ns := ex.Ref().Namespace() @@ -149,5 +164,8 @@ func (l *Library) setVisibleNamespaces() { if someWithoutNs { prepend = append(prepend, withoutNamespaceLabel) } + + l.mu.Lock() l.visibleNamespaces = append(prepend, labels...) //nolint:gocritic + l.mu.Unlock() } diff --git a/internal/io/library/library.go b/internal/io/library/library.go index 7a392c27..d2ea1ec2 100644 --- a/internal/io/library/library.go +++ b/internal/io/library/library.go @@ -2,6 +2,7 @@ package library import ( "fmt" + "sync" "github.com/charmbracelet/bubbles/viewport" "github.com/flowexec/tuikit" @@ -37,6 +38,9 @@ type Library struct { paneZeroViewport, paneOneViewport, paneTwoViewport viewport.Model cmdRunFunc func(string) error + + // Mutex to protect concurrent access to shared fields + mu sync.RWMutex } type Filter struct { diff --git a/internal/io/library/update.go b/internal/io/library/update.go index 0db49ed3..9a1e3cc1 100644 --- a/internal/io/library/update.go +++ b/internal/io/library/update.go @@ -77,21 +77,28 @@ func (l *Library) updateWsPane(msg tea.Msg) (viewport.Model, tea.Cmd) { return l.paneZeroViewport, nil } + l.mu.RLock() numWs := len(l.visibleWorkspaces) numNs := len(l.visibleNamespaces) if numWs == 0 { + l.mu.RUnlock() return l.paneZeroViewport, nil } curWs := l.visibleWorkspaces[l.currentWorkspace] + l.mu.RUnlock() + curWsCfg := l.allWorkspaces.FindByName(curWs) wsCanMoveUp := numWs > 1 && l.currentWorkspace >= 1 && l.currentWorkspace < uint(numWs) wsCanMoveDown := numWs > 1 && l.currentWorkspace < uint(numWs-1) var curNs string + l.mu.RLock() if len(l.visibleNamespaces) > 0 { curNs = l.visibleNamespaces[l.currentNamespace] } + l.mu.RUnlock() + nsCanMoveUp := curNs != "" && numNs > 1 && l.currentNamespace >= 1 && l.currentNamespace < uint(numNs) nsCanMoveDown := curNs != "" && numNs > 1 && l.currentNamespace < uint(numNs-1) @@ -198,12 +205,16 @@ func (l *Library) updateExecPanes(msg tea.Msg) (viewport.Model, tea.Cmd) { pane = l.paneTwoViewport } + l.mu.RLock() numExecs := len(l.visibleExecutables) if numExecs == 0 { + l.mu.RUnlock() return pane, nil } curExec := l.visibleExecutables[l.currentExecutable] + l.mu.RUnlock() + canMoveUp := numExecs > 1 && l.currentExecutable >= 1 && l.currentExecutable < uint(numExecs) canMoveDown := numExecs > 1 && l.currentExecutable < uint(numExecs-1) diff --git a/internal/io/library/view.go b/internal/io/library/view.go index 283d1ee8..bb3f536e 100644 --- a/internal/io/library/view.go +++ b/internal/io/library/view.go @@ -4,8 +4,6 @@ package library import ( "fmt" "math" - "os" - "path/filepath" "strings" "github.com/charmbracelet/lipgloss" @@ -102,8 +100,11 @@ func (l *Library) paneZeroContent() string { } var sb strings.Builder + l.mu.RLock() workspaces := l.visibleWorkspaces namespaces := l.visibleNamespaces + l.mu.RUnlock() + sb.WriteString(renderPaneTitle("Workspaces", len(workspaces), l.currentPane == 0, l.theme)) numWs := len(workspaces) @@ -158,8 +159,10 @@ func (l *Library) paneOneContent() string { } var sb strings.Builder + l.mu.RLock() sb.WriteString(renderPaneTitle("Executables", len(l.visibleExecutables), l.currentPane == 1, l.theme)) if len(l.visibleExecutables) == 0 { + l.mu.RUnlock() sb.WriteString(l.theme.RenderError("No executables found")) return sb.String() } @@ -171,7 +174,10 @@ func (l *Library) paneOneContent() string { if len(l.visibleNamespaces) > 0 { curNs = l.visibleNamespaces[l.currentNamespace] } - for i, ex := range l.visibleExecutables { + visibleExecutables := l.visibleExecutables + l.mu.RUnlock() + + for i, ex := range visibleExecutables { if uint(i) == l.currentExecutable { indicator := "*" if (l.ctx.CurrentWorkspace != nil && ex.Workspace() == l.ctx.CurrentWorkspace.AssignedName()) || @@ -190,12 +196,18 @@ func (l *Library) paneOneContent() string { } func (l *Library) paneTwoContent() string { + l.mu.RLock() if len(l.visibleExecutables) == 0 { + l.mu.RUnlock() return "" } else if !l.splitView && l.currentPane != 2 { + l.mu.RUnlock() return "" } + ex := l.visibleExecutables[l.currentExecutable] + l.mu.RUnlock() + _, _, maxWidth := calculateViewportWidths(l.termWidth, l.splitView) paneTwoMaxWidth := math.Floor(float64(maxWidth) * 0.95) mdStyles, err := l.theme.GlamourMarkdownStyleJSON() @@ -211,7 +223,6 @@ func (l *Library) paneTwoContent() string { return l.theme.RenderError(fmt.Sprintf("unable to render markdown: %s", err.Error())) } - ex := l.visibleExecutables[l.currentExecutable] content := ex.Markdown() switch l.currentFormat { case 0: @@ -255,37 +266,38 @@ func (l *Library) footerContent() string { ) } else if help { return l.theme.RenderFooter(fmt.Sprintf("%s ● %s", footerPrefix, paneZeroHelp), l.termWidth) - } else if l.currentWorkspace < uint(len(l.visibleWorkspaces)) { - ws := l.visibleWorkspaces[l.currentWorkspace] - if ws == allWorkspacesLabel { - break - } - var wsCfg *workspace.Workspace - for i, w := range l.allWorkspaces { - if w.AssignedName() == ws { - wsCfg = l.allWorkspaces[i] + } else { + l.mu.RLock() + if l.currentWorkspace < uint(len(l.visibleWorkspaces)) { + ws := l.visibleWorkspaces[l.currentWorkspace] + l.mu.RUnlock() + if ws == allWorkspacesLabel { + break + } + var wsCfg *workspace.Workspace + for i, w := range l.allWorkspaces { + if w.AssignedName() == ws { + wsCfg = l.allWorkspaces[i] + } + } + if wsCfg == nil { + l.ctx.Logger.Errorf("unable to find workspace config for %s", ws) + break } - } - if wsCfg == nil { - l.ctx.Logger.Errorf("unable to find workspace config for %s", ws) - break - } - path, err := relativePathFromWd(wsCfg.Location()) - if err != nil { - l.ctx.Logger.Error(err, "unable to get relative path from wd") - break - } - var info string - switch { - case l.noticeText != "": - info = l.noticeText - case len(wsCfg.Tags) > 0: - info = fmt.Sprintf("%s(%s) -> %s", wsCfg.DisplayName, common.Tags(wsCfg.Tags).PreviewString(), path) - default: - info = fmt.Sprintf("%s -> %s", wsCfg.DisplayName, path) + var info string + switch { + case l.noticeText != "": + info = l.noticeText + case len(wsCfg.Tags) > 0: + info = fmt.Sprintf("%s(%s) -> %s", wsCfg.DisplayName, common.Tags(wsCfg.Tags).PreviewString(), wsCfg.Location()) + default: + info = fmt.Sprintf("%s -> %s", wsCfg.DisplayName, wsCfg.Location()) + } + return l.theme.RenderFooter(fmt.Sprintf("%s ● %s", footerPrefix, info), l.termWidth) + } else { + l.mu.RUnlock() } - return l.theme.RenderFooter(fmt.Sprintf("%s ● %s", footerPrefix, info), l.termWidth) } case 1, 2: if help { @@ -297,30 +309,23 @@ func (l *Library) footerContent() string { return l.theme.RenderFooter( fmt.Sprintf("%s ● %s", footerPrefix, helpStr), l.termWidth, ) - } else if l.currentExecutable < uint(len(l.visibleExecutables)) { - var info string - switch { - case l.noticeText != "": - info = l.noticeText - default: - exec := l.visibleExecutables[l.currentExecutable] - path, err := relativePathFromWd(exec.FlowFilePath()) - if err != nil { - l.ctx.Logger.Error(err, "unable to get relative path from wd") - break + } else { + l.mu.RLock() + if l.currentExecutable < uint(len(l.visibleExecutables)) { + var info string + switch { + case l.noticeText != "": + info = l.noticeText + default: + exec := l.visibleExecutables[l.currentExecutable] + l.mu.RUnlock() + info = exec.FlowFilePath() } - info = path + return l.theme.RenderFooter(fmt.Sprintf("%s ● %s", footerPrefix, info), l.termWidth) + } else { + l.mu.RUnlock() } - return l.theme.RenderFooter(fmt.Sprintf("%s ● %s", footerPrefix, info), l.termWidth) } } return l.theme.RenderFooter(footerPrefix, l.termWidth) } - -func relativePathFromWd(path string) (string, error) { - wd, err := os.Getwd() - if err != nil { - return "", err - } - return filepath.Rel(wd, path) -} diff --git a/tests/browse_cmds_e2e_test.go b/tests/browse_cmds_e2e_test.go index 9cf62129..b91a007c 100644 --- a/tests/browse_cmds_e2e_test.go +++ b/tests/browse_cmds_e2e_test.go @@ -4,13 +4,157 @@ package tests_test import ( stdCtx "context" + "fmt" + stdIO "io" + "path/filepath" + "time" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/exp/teatest" + "github.com/flowexec/tuikit" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/flowexec/flow/internal/io" + execIO "github.com/flowexec/flow/internal/io/executable" + "github.com/flowexec/flow/internal/io/library" "github.com/flowexec/flow/tests/utils" + "github.com/flowexec/flow/types/executable" ) +var _ = Describe("browse TUI", func() { + var ( + ctx *utils.Context + container *tuikit.Container + + runChan chan string + runFunc func(ref string) error + ) + + BeforeEach(func() { + ctx = utils.NewContext(stdCtx.Background(), GinkgoT()) + runChan = make(chan string, 1) + runFunc = func(ref string) error { + runChan <- ref + return nil + } + + container = newTUIContainer(ctx.Ctx) + ctx.TUIContainer = container + }) + + AfterEach(func() { + ctx.Finalize() + }) + + Specify("narrow snapshot", func() { + tm := teatest.NewTestModel(GinkgoTB(), container, teatest.WithInitialTermSize(80, 25)) + container.Program().SetTeaProgram(tm.GetProgram()) + container.SetSendFunc(tm.Send) + + wsList, err := ctx.WorkspacesCache.GetWorkspaceConfigList(ctx.Logger) + Expect(err).NotTo(HaveOccurred()) + execList, err := ctx.ExecutableCache.GetExecutableList(ctx.Logger) + Expect(err).NotTo(HaveOccurred()) + + libraryView := library.NewLibraryView( + ctx.Context, wsList, execList, + library.Filter{}, + io.Theme(ctx.Config.Theme.String()), + runFunc, + ) + Expect(container.SetView(libraryView)).To(Succeed()) + + container.Send(tea.Quit(), 250*time.Millisecond) + tm.WaitFinished(GinkgoTB(), teatest.WithFinalTimeout(500*time.Millisecond)) + out, err := stdIO.ReadAll(tm.FinalOutput(GinkgoTB())) + Expect(err).NotTo(HaveOccurred()) + Expect(out).NotTo(BeEmpty()) + + // TODO: fix golden generation / normalization / comparison + // utils.MaybeUpdateGolden(GinkgoTB(), out) + // utils.RequireEqualSnapshot(GinkgoTB(), out) + }) + + Specify("wide snapshot", func() { + tm := teatest.NewTestModel(GinkgoTB(), container, teatest.WithInitialTermSize(150, 25)) + container.Program().SetTeaProgram(tm.GetProgram()) + container.SetSendFunc(tm.Send) + + wsList, err := ctx.WorkspacesCache.GetWorkspaceConfigList(ctx.Logger) + Expect(err).NotTo(HaveOccurred()) + execList, err := ctx.ExecutableCache.GetExecutableList(ctx.Logger) + Expect(err).NotTo(HaveOccurred()) + + libraryView := library.NewLibraryView( + ctx.Context, wsList, execList, + library.Filter{}, + io.Theme(ctx.Config.Theme.String()), + runFunc, + ) + Expect(container.SetView(libraryView)).To(Succeed()) + + container.Send(tea.Quit(), 250*time.Millisecond) + tm.WaitFinished(GinkgoTB(), teatest.WithFinalTimeout(500*time.Millisecond)) + out, err := stdIO.ReadAll(tm.FinalOutput(GinkgoTB())) + Expect(err).NotTo(HaveOccurred()) + Expect(out).NotTo(BeEmpty()) + + // TODO: fix golden generation / normalization / comparison + // utils.MaybeUpdateGolden(GinkgoTB(), out) + // utils.RequireEqualSnapshot(GinkgoTB(), out) + }) + + Specify("list snapshot", func() { + tm := teatest.NewTestModel(GinkgoTB(), container, teatest.WithInitialTermSize(80, 25)) + container.Program().SetTeaProgram(tm.GetProgram()) + container.SetSendFunc(tm.Send) + fmt.Println("Running executable list snapshot test...") + + execList, err := ctx.ExecutableCache.GetExecutableList(ctx.Logger) + Expect(err).NotTo(HaveOccurred()) + listView := execIO.NewExecutableListView(ctx.Context, execList, runFunc) + Expect(container.SetView(listView)).To(Succeed()) + + container.Send(tea.Quit(), 250*time.Millisecond) + tm.WaitFinished(GinkgoTB(), teatest.WithFinalTimeout(500*time.Millisecond)) + out, err := stdIO.ReadAll(tm.FinalOutput(GinkgoTB())) + Expect(err).NotTo(HaveOccurred()) + Expect(out).NotTo(BeEmpty()) + + // TODO: fix golden generation / normalization / comparison + // utils.MaybeUpdateGolden(GinkgoTB(), out) + // utils.RequireEqualSnapshot(GinkgoTB(), out) + }) + + Specify("exec snapshot", func() { + path := filepath.Join(ctx.WorkspaceDir(), "snapshot.flow") + exec := &executable.Executable{ + Verb: "show", + Name: "snapshot", + Exec: &executable.ExecExecutableType{Cmd: "echo 'Hello, world! This is a snapshot test.'"}, + } + exec.SetContext("default", ctx.WorkspaceDir(), "", path) + + tm := teatest.NewTestModel(GinkgoTB(), container, teatest.WithInitialTermSize(80, 25)) + container.Program().SetTeaProgram(tm.GetProgram()) + container.SetSendFunc(tm.Send) + + execView := execIO.NewExecutableView(ctx.Context, exec, runFunc) + Expect(container.SetView(execView)).To(Succeed()) + + container.Send(tea.Quit(), 250*time.Millisecond) + tm.WaitFinished(GinkgoTB(), teatest.WithFinalTimeout(500*time.Millisecond)) + out, err := stdIO.ReadAll(tm.FinalOutput(GinkgoTB())) + Expect(err).NotTo(HaveOccurred()) + Expect(out).NotTo(BeEmpty()) + + // TODO: fix golden generation / normalization / comparison + // utils.MaybeUpdateGolden(GinkgoTB(), out) + // utils.RequireEqualSnapshot(GinkgoTB(), out) + }) +}) + var _ = Describe("browse e2e", Ordered, func() { var ( ctx *utils.Context diff --git a/tests/e2e_test.go b/tests/e2e_test.go index de6ae496..b77fb73f 100644 --- a/tests/e2e_test.go +++ b/tests/e2e_test.go @@ -3,16 +3,24 @@ package tests_test import ( + "context" "fmt" "os" "strconv" + "github.com/charmbracelet/lipgloss" + "github.com/flowexec/tuikit" + "github.com/muesli/termenv" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "testing" ) +func init() { + lipgloss.SetColorProfile(termenv.Ascii) +} + func TestE2E(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "End-to-end Test Suite") @@ -31,3 +39,10 @@ func readFileContent(f *os.File) (string, error) { } return outStr, nil } + +func newTUIContainer(ctx context.Context) *tuikit.Container { + app := &tuikit.Application{Name: "flow-test"} + container, err := tuikit.NewContainer(ctx, app) + Expect(err).NotTo(HaveOccurred()) + return container +} diff --git a/tests/testdata/browse TUI exec snapshot.golden b/tests/testdata/browse TUI exec snapshot.golden new file mode 100644 index 00000000..3798c0cd --- /dev/null +++ b/tests/testdata/browse TUI exec snapshot.golden @@ -0,0 +1,25 @@ +[?25l[?2004h]2;flow-test ╭───────────╮ +│ flow-test │─────────────────────────────────────────────────────────────────── +╰───────────╯ +   [Executable] show default/snapshot  +   +  Shell Configuration +  +  Command + +  │ echo 'Hello, world! This is a snapshot test.' + +  Executable can be found in +  /TMPDIR/browse _tui35189330/default/ +  flow +  [ 🔗 +  /TMPDIR/browse _tui35189330/default/ +  flow +  ] + + + + + +──────────────────────────────────────────────────────────────────────────────── +[ q: quit ] [ h: show help ]  [?2004l[?25h[?1002l[?1003l[?1006l \ No newline at end of file diff --git a/tests/testdata/browse TUI list snapshot.golden b/tests/testdata/browse TUI list snapshot.golden new file mode 100644 index 00000000..0db95604 --- /dev/null +++ b/tests/testdata/browse TUI list snapshot.golden @@ -0,0 +1,33 @@ +[?25l[?2004h]2;flow-test ╭───────────╮ +│ flow-test │─────────────────────────────────────────────────────────────────── +╰───────────╯ + + + ●∙∙ loading... + + + ╭───────────╮ +│ flow-test │─────────────────────────────────────────────────────────────────── +╰───────────╯ + + 14 executables + + ║ run default/ + run default/examples:request + run default/examples:request-with-body + run default/examples:request-with-timeout + run default/examples:request-with-transform + run default/examples:request-with-validated-status + run default/examples:simple-print + run default/examples:with-args + run default/examples:with-exit + run default/examples:with-params + run default/examples:with-pauses + run default/examples:with-plaintext + run default/examples:with-timeout + run default/examples:with-tmp-dir + + + +──────────────────────────────────────────────────────────────────────────────── +[ q: quit ] [ h: show help ]  [?2004l[?25h[?1002l[?1003l[?1006l \ No newline at end of file diff --git a/tests/testdata/browse TUI narrow snapshot.golden b/tests/testdata/browse TUI narrow snapshot.golden new file mode 100644 index 00000000..45972b86 --- /dev/null +++ b/tests/testdata/browse TUI narrow snapshot.golden @@ -0,0 +1,25 @@ +[?25l[?2004h]2;flow-test]2;flow library │ flow library │ ctx:default/* ───────────────────────────────────────────────── +╰──────────────╯ + Workspaces (1) Executables (1) + + ● default ▶ run + 2 namespaces + + + + + + + + + + + + + + + + +──────────────────────────────────────────────────────────────────────────────── +[ q/ctrl+c: quit] [ h: help ] ● Default Workspace -> +/TMPDIR/browse _tui1729679775/default  [?2004l[?25h[?1002l[?1003l[?1006l \ No newline at end of file diff --git a/tests/testdata/browse TUI wide snapshot.golden b/tests/testdata/browse TUI wide snapshot.golden new file mode 100644 index 00000000..00ca40ea --- /dev/null +++ b/tests/testdata/browse TUI wide snapshot.golden @@ -0,0 +1,33 @@ +[?25l[?2004h]2;flow-test ╭───────────╮ +│ flow-test │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +╰───────────╯ + + + ●∙∙ loading... + + + ]2;flow library╭──────────────╮ +│ flow library │ ctx:default/* ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +╰──────────────╯ + Workspaces (1) Executables (1) ▌   [Executable] run default/  + ▌  + ● default ▶ run ▌  Visibility: private + 2 namespaces ▌  Tags + ▌ + ▌  ‣  generated  + ▌   + ▌  Shell Configuration + ▌  + ▌  Command + ▌ + ▌  │ echo 'hello from nameless' + ▌ + ▌  Executable can be found in + ▌  /TMPDIR/browse _tui604194612/ + ▌  flow + ▌  [ 🔗 + ▌  /TMPDIR/browse _tui604194612/ + ▌  flow + ▌  ] +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +[ q/ctrl+c: quit] [ h: help ] ● Default Workspace -> /TMPDIR/browse _tui604194612/default  [?2004l[?25h[?1002l[?1003l[?1006l \ No newline at end of file diff --git a/tests/utils/context.go b/tests/utils/context.go index 8a16d197..1834103f 100644 --- a/tests/utils/context.go +++ b/tests/utils/context.go @@ -37,6 +37,11 @@ type Context struct { *context.Context cacheDir string configDir string + wsDir string +} + +func (c *Context) WorkspaceDir() string { + return c.wsDir } // NewContext creates a new context for testing runners. It initializes the context with @@ -54,11 +59,12 @@ func NewContext(ctx stdCtx.Context, t ginkgo.FullGinkgoTInterface) *Context { t.Fatalf("logger exit called - %s", msg) }), ) - ctxx, configDir, cacheDir := newTestContext(ctx, t, logger, stdIn, stdOut) + ctxx, configDir, cacheDir, wsDir := newTestContext(ctx, t, logger, stdIn, stdOut) return &Context{ Context: ctxx, configDir: configDir, cacheDir: cacheDir, + wsDir: wsDir, } } @@ -145,7 +151,7 @@ func newTestContext( t ginkgo.FullGinkgoTInterface, logger tuikitIO.Logger, stdIn, stdOut *os.File, -) (*context.Context, string, string) { +) (*context.Context, string, string, string) { configDir, cacheDir, wsDir := initTestDirectories(t) setTestEnv(t, configDir, cacheDir) @@ -174,7 +180,7 @@ func newTestContext( ExecutableCache: execCache, } ctxx.SetIO(stdIn, stdOut) - return ctxx, configDir, cacheDir + return ctxx, configDir, cacheDir, wsDir } func initTestDirectories(t ginkgo.FullGinkgoTInterface) (string, string, string) { diff --git a/tests/utils/golden.go b/tests/utils/golden.go new file mode 100644 index 00000000..9386de95 --- /dev/null +++ b/tests/utils/golden.go @@ -0,0 +1,59 @@ +package utils + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/charmbracelet/x/exp/teatest" +) + +var updateEnvKey = "UPDATE_GOLDEN_FILES" + +func UpdateGolden(tb testing.TB, out []byte) { + golden := filepath.Join("testdata", tb.Name()+".golden") + if err := os.MkdirAll(filepath.Dir(golden), 0o755); err != nil { + tb.Fatal(err) + } + normalized := NormalizeTmpDirs(string(out)) + if err := os.WriteFile(golden, []byte(normalized), 0o600); err != nil { + tb.Fatal(err) + } +} + +func MaybeUpdateGolden(tb testing.TB, out []byte) { + if os.Getenv(updateEnvKey) == "true" { + UpdateGolden(tb, out) + } +} + +// NormalizeTmpDirs replaces all file paths under the system temp dir with /TMPDIR. +func NormalizeTmpDirs(input string) string { + tmpDir := os.TempDir() + if !strings.HasSuffix(tmpDir, "/") { + tmpDir += "/" + } + tmpDirPattern := regexp.QuoteMeta(tmpDir) + // Match temp dir followed by any path plus any trailing whitespace + re := regexp.MustCompile(tmpDirPattern + `[^\s\x00-\x1F]+`) + result := re.ReplaceAllStringFunc(input, func(m string) string { + pathAfterTmp := m[len(tmpDir):] + // Trim whitespace from the captured path + // pathAfterTmp = strings.TrimSpace(pathAfterTmp) + parts := strings.Split(pathAfterTmp, "/") + if len(parts) >= 2 { + // Keep only the last meaningful part + return "/TMPDIR/" + parts[len(parts)-1] + } + return "/TMPDIR/" + pathAfterTmp + " " + }) + + return result +} + +func RequireEqualSnapshot(tb testing.TB, out []byte) { + normalized := NormalizeTmpDirs(string(out)) + teatest.RequireEqualOutput(tb, []byte(normalized)) +}