1package daemon
2
3import (
4 "errors"
5 "sync/atomic"
6 "testing"
7 "time"
8
9 "github.com/stretchr/testify/require"
10
11 "github.com/bnema/vev/internal/domain"
12 portsmocks "github.com/bnema/vev/internal/ports/mocks"
13)
14
15func TestFormatPaneTitle(t *testing.T) {
16 tests := []struct {
17 name, process, terminal, fallback, want string
18 }{
19 {name: "process prepends non-prefixed terminal title", process: "nvim", terminal: "project/main.go", fallback: "sh", want: "nvim: project/main.go"},
20 {name: "process does not duplicate its terminal-title prefix", process: "zot", terminal: "zot: session title", fallback: "sh", want: "zot: session title"},
21 {name: "process only", process: "nvim", fallback: "sh", want: "nvim"},
22 {name: "terminal only", terminal: "project/main.go", fallback: "sh", want: "project/main.go"},
23 {name: "fallback", fallback: "fish", want: "fish"},
24 }
25 for _, tt := range tests {
26 t.Run(tt.name, func(t *testing.T) {
27 require.Equal(t, tt.want, formatPaneTitle(tt.process, tt.terminal, tt.fallback))
28 })
29 }
30}
31
32func TestComposeTabTitle(t *testing.T) {
33 tests := []struct {
34 name, tabName, paneTitle, want string
35 }{
36 {name: "name and title", tabName: "api", paneTitle: "nvim: server.go — vev", want: "api (nvim: server.go — vev)"},
37 {name: "empty pane title", tabName: "api", paneTitle: "", want: "api"},
38 {name: "pane title equals name", tabName: "api", paneTitle: "api", want: "api"},
39 {name: "unnamed tab style", tabName: "1", paneTitle: "zsh", want: "1 (zsh)"},
40 }
41 for _, tt := range tests {
42 t.Run(tt.name, func(t *testing.T) {
43 require.Equal(t, tt.want, composeTabTitle(tt.tabName, tt.paneTitle))
44 })
45 }
46}
47
48func TestPaneTerminalTitleGenerationChangesOnlyWhenTitleChanges(t *testing.T) {
49 p := newPane("pane", nil, domain.Size{Cols: 20, Rows: 5})
50 p.mu.Lock()
51 p.screen.Write([]byte("\x1b]2;project/main.go\a"))
52 p.refreshTerminalTitleLocked()
53 first := p.title.generation
54 p.refreshTerminalTitleLocked()
55 second := p.title.generation
56 p.screen.Write([]byte("\x1b]0;other\a"))
57 p.refreshTerminalTitleLocked()
58 third := p.title.generation
59 p.mu.Unlock()
60
61 require.Equal(t, uint64(1), first)
62 require.Equal(t, first, second)
63 require.Equal(t, first+1, third)
64}
65
66func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) {
67 pty := portsmocks.NewMockPTY(t)
68 pty.EXPECT().ForegroundPgid().Return(1234, nil).Twice()
69 _, sess, _, _ := newManualSessionWithPTYs(t, pty)
70 clk := portsmocks.NewMockClock(t)
71 clk.EXPECT().Now().Return(time.Time{}).Maybe()
72 d := newTestDaemon(t, nil, clk)
73 d.shell = "/bin/zsh"
74 d.procComm = func(pid int) (string, error) {
75 require.Equal(t, 1234, pid)
76 return "vim\n", nil
77 }
78
79 title := d.refreshPaneTitle(sess, "pane-1")
80 require.Equal(t, "vim", title)
81
82 p := testAttachmentTab(sess).focusedPane()
83 p.mu.Lock()
84 p.screen.Write([]byte("\x1b]2;project/main.go\a"))
85 p.refreshTerminalTitleLocked()
86 p.mu.Unlock()
87 title = d.refreshPaneDisplayTitle(sess, p, true)
88
89 require.Equal(t, "vim: project/main.go", title)
90 require.Equal(t, "vim", p.title.processName)
91}
92
93func TestRefreshPaneTitleUsesProvidedOwningTab(t *testing.T) {
94 secondPTY := portsmocks.NewMockPTY(t)
95 secondPTY.EXPECT().ForegroundPgid().Return(222, nil).Once()
96 _, sess, _, _ := newManualSessionWithPTYs(t, nil)
97 second := newTabWithStableID("second-tab", "second-pane", secondPTY, domain.Size{Cols: 80, Rows: 23})
98 sess.mu.Lock()
99 sess.tabs = append(sess.tabs, second)
100 sess.mu.Unlock()
101 d := newTestDaemon(t, nil, stubClock{})
102 d.procComm = func(pid int) (string, error) {
103 require.Equal(t, 222, pid)
104 return "vim", nil
105 }
106
107 require.Equal(t, "vim", d.refreshPaneTitle(sess, "pane-1", second))
108 secondPane := second.panes["pane-1"]
109 secondPane.mu.Lock()
110 require.Equal(t, "vim", secondPane.title.processName)
111 secondPane.mu.Unlock()
112}
113
114func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) {
115 pty := portsmocks.NewMockPTY(t)
116 pty.EXPECT().ForegroundPgid().Return(1234, nil).Twice()
117 _, sess, _, _ := newManualSessionWithPTYs(t, pty)
118 clk := portsmocks.NewMockClock(t)
119 now := time.Unix(10, 0)
120 clk.EXPECT().Now().RunAndReturn(func() time.Time { return now }).Maybe()
121 d := newTestDaemon(t, nil, clk)
122 var calls atomic.Int32
123 d.procComm = func(pid int) (string, error) {
124 require.Equal(t, 1234, pid)
125 calls.Add(1)
126 return "vim\n", nil
127 }
128
129 require.Equal(t, "vim", d.refreshPaneTitle(sess, "pane-1"))
130 now = now.Add(paneTitleCacheTTL / 2)
131 require.Equal(t, "vim", d.refreshPaneTitle(sess, "pane-1"))
132 require.Equal(t, int32(1), calls.Load(), "paint refresh inside TTL should use cached title")
133 require.Equal(t, "vim", d.refreshPaneTitleOnFocus(sess, "pane-1"))
134 require.Equal(t, int32(2), calls.Load(), "focus refresh should bypass TTL")
135}
136
137func TestRefreshFloatingPaneTitlePreservesConfiguredCommandFallback(t *testing.T) {
138 pty := portsmocks.NewMockPTY(t)
139 pty.EXPECT().ForegroundPgid().Return(0, errors.New("no foreground process")).Twice()
140 clk := portsmocks.NewMockClock(t)
141 clk.EXPECT().Now().Return(time.Time{}).Maybe()
142 d := newTestDaemon(t, nil, clk)
143 d.shell = "/usr/bin/fish"
144 d.procComm = func(int) (string, error) { return "", errors.New("unused") }
145 p := newPane("floating", pty, domain.Size{Cols: 20, Rows: 5})
146 p.title.displayFallback = floatingCommandFallback("btop --utf", d.shell)
147
148 require.Equal(t, "btop", d.refreshPaneDisplayTitle(nil, p, false))
149 require.Equal(t, "btop", d.refreshPaneDisplayTitle(nil, p, false), "cached refresh must retain the pane-owned fallback")
150 require.Equal(t, "btop", d.refreshPaneDisplayTitle(nil, p, true), "process refresh must retain the pane-owned fallback")
151 p.mu.Lock()
152 require.Equal(t, "btop", p.title.displayFallback)
153 p.mu.Unlock()
154}
155
156func TestRefreshFloatingPaneTitleUsesShellFallbackForEmptyCommand(t *testing.T) {
157 d := newTestDaemon(t, nil, stubClock{})
158 d.shell = "/usr/local/bin/zsh"
159 p := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
160 p.title.displayFallback = floatingCommandFallback("", d.shell)
161
162 require.Equal(t, "zsh", d.refreshPaneDisplayTitle(nil, p, true))
163}
164
165func TestRefreshPaneTitleRetainsNormalPaneShellFallback(t *testing.T) {
166 d, sess, _, _ := newManualSessionWithPTYs(t, nil)
167 p := testAttachmentTab(sess).focusedPane()
168 p.setDisplayFallback("fish")
169
170 d.shell = "/bin/zsh"
171 require.Equal(t, "fish", d.refreshPaneTitle(sess, "pane-1"))
172 p.mu.Lock()
173 firstGeneration := p.title.generation
174 p.mu.Unlock()
175 require.Equal(t, "fish", d.refreshPaneTitle(sess, "pane-1"))
176 p.mu.Lock()
177 require.Equal(t, firstGeneration, p.title.generation, "a title refresh must preserve the pane-owned shell fallback")
178 p.mu.Unlock()
179}
180
181func TestRefreshPaneTitleLookupFailureKeepsProcessNameEmpty(t *testing.T) {
182 pty := portsmocks.NewMockPTY(t)
183 pty.EXPECT().ForegroundPgid().Return(0, errors.New("no foreground process")).Twice()
184 _, sess, _, _ := newManualSessionWithPTYs(t, pty)
185 clk := portsmocks.NewMockClock(t)
186 clk.EXPECT().Now().Return(time.Time{}).Maybe()
187 d := newTestDaemon(t, nil, clk)
188 d.shell = "/usr/bin/fish"
189 d.procComm = func(int) (string, error) { return "", errors.New("unused") }
190 p := testAttachmentTab(sess).focusedPane()
191
192 p.mu.Lock()
193 p.screen.Write([]byte("\x1b]2;terminal\a"))
194 p.refreshTerminalTitleLocked()
195 p.mu.Unlock()
196 require.Equal(t, "terminal", d.refreshPaneDisplayTitle(sess, p, true))
197 p.mu.Lock()
198 require.Empty(t, p.title.processName, "a lookup failure must not become process state")
199 p.mu.Unlock()
200
201 p.mu.Lock()
202 p.screen.Write([]byte("\x1b]2;\a"))
203 p.refreshTerminalTitleLocked()
204 p.mu.Unlock()
205 require.Equal(t, "sh", d.refreshPaneDisplayTitle(sess, p, true), "pointer refresh must retain its pane-owned fallback")
206}