detached 3e25d6d

refactor(daemon): split daemon tests

commit
3e25d6d2ef05112638c35dfd7ee0293e38a5f50a
author
brice · · 2026-07-03 05:16 UTC
committer
brice · · 2026-07-03 07:33 UTC
parent
37fcb4a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +2186 -2062
A internal/usecase/daemon/copymode_test.go +383 -0
@@ -0,0 +1,383 @@
1+package daemon
2+
3+import (
4+ "encoding/base64"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "strings"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/ports"
18+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
19+ scopy "github.com/bnema/vev/internal/usecase/copy"
20+ "github.com/bnema/vev/pkg/renderer"
21+)
22+
23+// --- test doubles -----------------------------------------------------------
24+
25+// stubClock returns timers whose channel never fires, so a scheduler under it
26+// blocks in its debounce loop until the session context is cancelled. Used by
27+
28+func TestScrollbackModeAltUInterceptsAndDoesNotForward(t *testing.T) {
29+ writes := make(chan []byte, 1)
30+ p, _ := newBlockingPTYWithWrites(t, writes)
31+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
32+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
33+ sess.tabs[0].screen.Write([]byte("live"))
34+
35+ d.handleInput(sess, ac, []byte("\x1bu"))
36+
37+ if ac.copyMode == nil {
38+ t.Fatal("scrollback mode not entered")
39+ }
40+ select {
41+ case got := <-writes:
42+ t.Fatalf("scrollback binding forwarded to PTY: %q", got)
43+ default:
44+ }
45+ out := awaitFrame(t, sends, ports.MsgOutput)
46+ msg, err := ports.UnmarshalOutput(out.Payload)
47+ require.NoError(t, err)
48+ if got := string(msg.Data); !strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[COPY]") {
49+ t.Fatalf("scrollback mode paint = %q, want [SCROLL] without [COPY]", got)
50+ }
51+
52+ d.handleInput(sess, ac, []byte(" "))
53+ out = awaitFrame(t, sends, ports.MsgOutput)
54+ msg, err = ports.UnmarshalOutput(out.Payload)
55+ require.NoError(t, err)
56+ if got := string(msg.Data); !strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[SCROLL]") {
57+ t.Fatalf("visual selection paint = %q, want [VISUAL] without [SCROLL]", got)
58+ }
59+}
60+
61+func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
62+ writes := make(chan []byte, 1)
63+ p, _ := newBlockingPTYWithWrites(t, writes)
64+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
65+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
66+ sess.tabs[0].scrollback.Append(testRow("old1 "))
67+ sess.tabs[0].scrollback.Append(testRow("old2 "))
68+ sess.tabs[0].screen.Write([]byte("live"))
69+
70+ d.enterCopyMode(sess, ac)
71+ awaitFrame(t, sends, ports.MsgOutput)
72+ d.handleInput(sess, ac, []byte{'g', ' ', 'j', 'y'})
73+
74+ select {
75+ case got := <-writes:
76+ t.Fatalf("copy-mode navigation forwarded to PTY: %q", got)
77+ default:
78+ }
79+ out := awaitFrame(t, sends, ports.MsgOutput)
80+ msg, err := ports.UnmarshalOutput(out.Payload)
81+ require.NoError(t, err)
82+ if got, want := string(msg.Data), "\x1b]52;c;b2xkMQpvbGQy\x07"; got != want {
83+ t.Fatalf("OSC52 = %q, want %q", got, want)
84+ }
85+ if ac.copyMode != nil {
86+ t.Fatal("copy mode still active after yank")
87+ }
88+ live := awaitFrame(t, sends, ports.MsgOutput)
89+ liveMsg, err := ports.UnmarshalOutput(live.Payload)
90+ require.NoError(t, err)
91+ if strings.Contains(string(liveMsg.Data), "[COPY]") || strings.Contains(string(liveMsg.Data), "[SCROLL]") {
92+ t.Fatalf("live repaint still contains copy/scroll status: %q", string(liveMsg.Data))
93+ }
94+ if !strings.Contains(string(liveMsg.Data), "copied 9 chars to clipboard") {
95+ t.Fatalf("live repaint = %q, want copy success feedback", string(liveMsg.Data))
96+ }
97+ if !strings.Contains(string(liveMsg.Data), "live") {
98+ t.Fatalf("live repaint = %q, want live screen", string(liveMsg.Data))
99+ }
100+
101+ d.paint(sess, ac, true)
102+ followup := awaitFrame(t, sends, ports.MsgOutput)
103+ followupMsg, err := ports.UnmarshalOutput(followup.Payload)
104+ require.NoError(t, err)
105+ if strings.Contains(string(followupMsg.Data), "copied 9 chars to clipboard") {
106+ t.Fatalf("copy feedback persisted after next repaint: %q", string(followupMsg.Data))
107+ }
108+}
109+
110+func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
111+ p := portsmocks.NewMockPTY(t)
112+ reads := make(chan []byte, 64)
113+ readDone := make(chan struct{})
114+ var closeOnce sync.Once
115+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
116+ select {
117+ case data := <-reads:
118+ return copy(b, data), nil
119+ case <-readDone:
120+ return 0, io.EOF
121+ }
122+ }).Maybe()
123+ p.EXPECT().Write(mock.Anything).Return(0, errors.New("unexpected PTY write")).Maybe()
124+ p.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
125+ p.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(readDone) }); return nil }).Maybe()
126+ p.EXPECT().Pid().Return(4242).Maybe()
127+
128+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
129+ tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 16, Rows: 5}))
130+ var hg sync.WaitGroup
131+ hg.Go(func() { d.handleConn(tr) })
132+ awaitFrame(t, sends, ports.MsgWelcome)
133+ awaitFrame(t, sends, ports.MsgOutput)
134+
135+ for i := range 12 {
136+ reads <- fmt.Appendf(nil, "line-%02d\r\n", i)
137+ }
138+ require.Eventually(t, func() bool {
139+ sess := firstSession(d)
140+ if sess == nil {
141+ return false
142+ }
143+ win := sess.activeTab()
144+ if win == nil {
145+ return false
146+ }
147+ win.mu.Lock()
148+ defer win.mu.Unlock()
149+ return len(scopy.NewSnapshot(win.scrollback, win.screen.Frame).Rows) >= 12
150+ }, 2*time.Second, 5*time.Millisecond)
151+
152+ sess := firstSession(d)
153+ require.NotNil(t, sess)
154+ ac := sess.client
155+ require.NotNil(t, ac)
156+ d.handleInput(sess, ac, []byte("\x1bu"))
157+ awaitFrame(t, sends, ports.MsgOutput)
158+ d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'})
159+
160+ var payload string
161+ require.Eventually(t, func() bool {
162+ out := awaitFrame(t, sends, ports.MsgOutput)
163+ msg, err := ports.UnmarshalOutput(out.Payload)
164+ require.NoError(t, err)
165+ payload = string(msg.Data)
166+ return strings.HasPrefix(payload, "\x1b]52;c;")
167+ }, 2*time.Second, 5*time.Millisecond)
168+ require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload)
169+ require.True(t, strings.HasSuffix(payload, "\a"), "OSC52 payload = %q", payload)
170+ decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a"))
171+ require.NoError(t, err)
172+ require.True(t, strings.HasPrefix(string(decoded), "line-00"), "decoded yank = %q", string(decoded))
173+
174+ releaseConn()
175+ closeOnce.Do(func() { close(readDone) })
176+ hg.Wait()
177+ d.sessWg.Wait()
178+}
179+
180+func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
181+ p, _ := newBlockingPTY(t)
182+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
183+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
184+ sess.tabs[0].screen.Write([]byte("live"))
185+
186+ d.enterCopyMode(sess, ac)
187+ awaitFrame(t, sends, ports.MsgOutput)
188+ d.handleInput(sess, ac, []byte("q"))
189+
190+ if ac.copyMode != nil {
191+ t.Fatal("copy mode still active after q")
192+ }
193+ out := awaitFrame(t, sends, ports.MsgOutput)
194+ msg, err := ports.UnmarshalOutput(out.Payload)
195+ require.NoError(t, err)
196+ if strings.Contains(string(msg.Data), "[COPY]") || !strings.Contains(string(msg.Data), "live") {
197+ t.Fatalf("exit repaint = %q, want live full repaint without copy status", string(msg.Data))
198+ }
199+}
200+
201+func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
202+ cases := []struct {
203+ name string
204+ input [][]byte
205+ wantCursor int
206+ }{
207+ {name: "escape then up arrow", input: [][]byte{[]byte("\x1b"), []byte("[A")}, wantCursor: 23},
208+ {name: "escape then down arrow", input: [][]byte{[]byte("g"), []byte("\x1b"), []byte("[B")}, wantCursor: 1},
209+ {name: "split up arrow", input: [][]byte{[]byte("\x1b["), []byte("A")}, wantCursor: 23},
210+ {name: "split page down", input: [][]byte{[]byte("g"), []byte("\x1b[6"), []byte("~")}, wantCursor: 23},
211+ }
212+ for _, tc := range cases {
213+ t.Run(tc.name, func(t *testing.T) {
214+ p, _ := newBlockingPTY(t)
215+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
216+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
217+ sess.tabs[0].scrollback.Append(testRow("old1 "))
218+ sess.tabs[0].scrollback.Append(testRow("old2 "))
219+ sess.tabs[0].screen.Write([]byte("live"))
220+
221+ d.enterCopyMode(sess, ac)
222+ awaitFrame(t, sends, ports.MsgOutput)
223+ for _, input := range tc.input {
224+ d.handleInput(sess, ac, input)
225+ }
226+
227+ require.NotNil(t, ac.copyMode)
228+ require.Equal(t, tc.wantCursor, ac.copyMode.Cursor)
229+ })
230+ }
231+}
232+
233+func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
234+ p, _ := newBlockingPTY(t)
235+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
236+ sess.tabs[0].scrollback = scopy.NewScrollback(1)
237+ longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)
238+ sess.tabs[0].screen.Frame = renderer.NewFrame(len(longLine), 1)
239+ copy(sess.tabs[0].screen.Frame.Row(0), testRow(longLine))
240+
241+ d.enterCopyMode(sess, ac)
242+ awaitFrame(t, sends, ports.MsgOutput)
243+ d.handleInput(sess, ac, []byte{' ', 'y'})
244+
245+ out := awaitFrame(t, sends, ports.MsgOutput)
246+ msg, err := ports.UnmarshalOutput(out.Payload)
247+ require.NoError(t, err)
248+ if strings.Contains(string(msg.Data), "\x1b]52;") {
249+ t.Fatalf("oversized yank emitted OSC52: %q", string(msg.Data))
250+ }
251+ if !strings.Contains(string(msg.Data), "selection too large to copy") {
252+ t.Fatalf("oversized yank repaint = %q, want too-large feedback", string(msg.Data))
253+ }
254+}
255+
256+func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
257+ p, _ := newBlockingPTY(t)
258+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
259+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
260+ d.clock = clk
261+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
262+ sess.tabs[0].screen.Write([]byte("live"))
263+
264+ d.enterCopyMode(sess, ac)
265+ awaitFrame(t, sends, ports.MsgOutput)
266+ d.handleInput(sess, ac, []byte("\x1b"))
267+ timer := <-clk.timers
268+ ac.copyMu.Lock()
269+ require.NotNil(t, ac.copyMode)
270+ ac.copyMu.Unlock()
271+ timer.ch <- time.Now()
272+ require.Eventually(t, func() bool {
273+ ac.copyMu.Lock()
274+ defer ac.copyMu.Unlock()
275+ return ac.copyMode == nil
276+ }, time.Second, 5*time.Millisecond)
277+}
278+
279+func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
280+ p, _ := newBlockingPTY(t)
281+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
282+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
283+ d.clock = clk
284+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
285+ sess.tabs[0].screen.Write([]byte("live"))
286+
287+ d.enterCopyMode(sess, ac)
288+ awaitFrame(t, sends, ports.MsgOutput)
289+ d.handleInput(sess, ac, []byte("\x1b"))
290+ timer := <-clk.timers
291+ d.enterCopyMode(sess, ac)
292+ awaitFrame(t, sends, ports.MsgOutput)
293+
294+ timer.ch <- time.Now()
295+ require.Never(t, func() bool {
296+ ac.copyMu.Lock()
297+ defer ac.copyMu.Unlock()
298+ return ac.copyMode == nil
299+ }, 50*time.Millisecond, 5*time.Millisecond)
300+ ac.copyMu.Lock()
301+ require.Nil(t, ac.copyESC.timer)
302+ require.Nil(t, ac.copyESC.done)
303+ require.Empty(t, ac.copyPending)
304+ ac.copyMu.Unlock()
305+}
306+
307+func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
308+ cases := []struct {
309+ name string
310+ input []byte
311+ }{
312+ {name: "yank", input: []byte("y")},
313+ {name: "enter", input: []byte("\r")},
314+ }
315+ for _, tc := range cases {
316+ t.Run(tc.name, func(t *testing.T) {
317+ p, _ := newBlockingPTY(t)
318+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
319+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
320+ sess.tabs[0].screen.Write([]byte("live"))
321+
322+ d.enterCopyMode(sess, ac)
323+ awaitFrame(t, sends, ports.MsgOutput)
324+ d.handleInput(sess, ac, tc.input)
325+
326+ if ac.copyMode != nil {
327+ t.Fatal("copy mode still active after empty yank")
328+ }
329+ out := awaitFrame(t, sends, ports.MsgOutput)
330+ msg, err := ports.UnmarshalOutput(out.Payload)
331+ require.NoError(t, err)
332+ if strings.Contains(string(msg.Data), "\x1b]52;") {
333+ t.Fatalf("empty yank emitted OSC52 clipboard clear: %q", string(msg.Data))
334+ }
335+ if strings.Contains(string(msg.Data), "[COPY]") || !strings.Contains(string(msg.Data), "live") {
336+ t.Fatalf("empty yank repaint = %q, want live full repaint", string(msg.Data))
337+ }
338+ })
339+ }
340+}
341+
342+func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
343+ p, _ := newBlockingPTY(t)
344+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
345+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
346+ sess.tabs[0].screen.Write([]byte("live"))
347+
348+ done := make(chan struct{})
349+ var drain sync.WaitGroup
350+ drain.Go(func() {
351+ for {
352+ select {
353+ case <-sends:
354+ case <-done:
355+ return
356+ }
357+ }
358+ })
359+
360+ var wg sync.WaitGroup
361+ for range 100 {
362+ wg.Go(func() {
363+ d.enterCopyMode(sess, ac)
364+ d.handleInput(sess, ac, []byte("q"))
365+ })
366+ wg.Go(func() {
367+ d.paint(sess, ac, true)
368+ })
369+ }
370+ wg.Wait()
371+ close(done)
372+ drain.Wait()
373+}
374+
375+func TestCursorTailHidesInCopyMode(t *testing.T) {
376+ p, _ := newBlockingPTY(t)
377+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
378+ sess.tabs[0].screen.Write([]byte("live"))
379+
380+ d.enterCopyMode(sess, ac)
381+ data := mustOutputData(t, sends)
382+ require.Contains(t, string(data), "\x1b[?25l")
383+}
@@ -0,0 +1,383 @@
1+package daemon
2+
3+import (
4+ "encoding/base64"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "strings"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/ports"
18+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
19+ scopy "github.com/bnema/vev/internal/usecase/copy"
20+ "github.com/bnema/vev/pkg/renderer"
21+)
22+
23+// --- test doubles -----------------------------------------------------------
24+
25+// stubClock returns timers whose channel never fires, so a scheduler under it
26+// blocks in its debounce loop until the session context is cancelled. Used by
27+
28+func TestScrollbackModeAltUInterceptsAndDoesNotForward(t *testing.T) {
29+ writes := make(chan []byte, 1)
30+ p, _ := newBlockingPTYWithWrites(t, writes)
31+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
32+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
33+ sess.tabs[0].screen.Write([]byte("live"))
34+
35+ d.handleInput(sess, ac, []byte("\x1bu"))
36+
37+ if ac.copyMode == nil {
38+ t.Fatal("scrollback mode not entered")
39+ }
40+ select {
41+ case got := <-writes:
42+ t.Fatalf("scrollback binding forwarded to PTY: %q", got)
43+ default:
44+ }
45+ out := awaitFrame(t, sends, ports.MsgOutput)
46+ msg, err := ports.UnmarshalOutput(out.Payload)
47+ require.NoError(t, err)
48+ if got := string(msg.Data); !strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[COPY]") {
49+ t.Fatalf("scrollback mode paint = %q, want [SCROLL] without [COPY]", got)
50+ }
51+
52+ d.handleInput(sess, ac, []byte(" "))
53+ out = awaitFrame(t, sends, ports.MsgOutput)
54+ msg, err = ports.UnmarshalOutput(out.Payload)
55+ require.NoError(t, err)
56+ if got := string(msg.Data); !strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[SCROLL]") {
57+ t.Fatalf("visual selection paint = %q, want [VISUAL] without [SCROLL]", got)
58+ }
59+}
60+
61+func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
62+ writes := make(chan []byte, 1)
63+ p, _ := newBlockingPTYWithWrites(t, writes)
64+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
65+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
66+ sess.tabs[0].scrollback.Append(testRow("old1 "))
67+ sess.tabs[0].scrollback.Append(testRow("old2 "))
68+ sess.tabs[0].screen.Write([]byte("live"))
69+
70+ d.enterCopyMode(sess, ac)
71+ awaitFrame(t, sends, ports.MsgOutput)
72+ d.handleInput(sess, ac, []byte{'g', ' ', 'j', 'y'})
73+
74+ select {
75+ case got := <-writes:
76+ t.Fatalf("copy-mode navigation forwarded to PTY: %q", got)
77+ default:
78+ }
79+ out := awaitFrame(t, sends, ports.MsgOutput)
80+ msg, err := ports.UnmarshalOutput(out.Payload)
81+ require.NoError(t, err)
82+ if got, want := string(msg.Data), "\x1b]52;c;b2xkMQpvbGQy\x07"; got != want {
83+ t.Fatalf("OSC52 = %q, want %q", got, want)
84+ }
85+ if ac.copyMode != nil {
86+ t.Fatal("copy mode still active after yank")
87+ }
88+ live := awaitFrame(t, sends, ports.MsgOutput)
89+ liveMsg, err := ports.UnmarshalOutput(live.Payload)
90+ require.NoError(t, err)
91+ if strings.Contains(string(liveMsg.Data), "[COPY]") || strings.Contains(string(liveMsg.Data), "[SCROLL]") {
92+ t.Fatalf("live repaint still contains copy/scroll status: %q", string(liveMsg.Data))
93+ }
94+ if !strings.Contains(string(liveMsg.Data), "copied 9 chars to clipboard") {
95+ t.Fatalf("live repaint = %q, want copy success feedback", string(liveMsg.Data))
96+ }
97+ if !strings.Contains(string(liveMsg.Data), "live") {
98+ t.Fatalf("live repaint = %q, want live screen", string(liveMsg.Data))
99+ }
100+
101+ d.paint(sess, ac, true)
102+ followup := awaitFrame(t, sends, ports.MsgOutput)
103+ followupMsg, err := ports.UnmarshalOutput(followup.Payload)
104+ require.NoError(t, err)
105+ if strings.Contains(string(followupMsg.Data), "copied 9 chars to clipboard") {
106+ t.Fatalf("copy feedback persisted after next repaint: %q", string(followupMsg.Data))
107+ }
108+}
109+
110+func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
111+ p := portsmocks.NewMockPTY(t)
112+ reads := make(chan []byte, 64)
113+ readDone := make(chan struct{})
114+ var closeOnce sync.Once
115+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
116+ select {
117+ case data := <-reads:
118+ return copy(b, data), nil
119+ case <-readDone:
120+ return 0, io.EOF
121+ }
122+ }).Maybe()
123+ p.EXPECT().Write(mock.Anything).Return(0, errors.New("unexpected PTY write")).Maybe()
124+ p.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
125+ p.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(readDone) }); return nil }).Maybe()
126+ p.EXPECT().Pid().Return(4242).Maybe()
127+
128+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
129+ tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 16, Rows: 5}))
130+ var hg sync.WaitGroup
131+ hg.Go(func() { d.handleConn(tr) })
132+ awaitFrame(t, sends, ports.MsgWelcome)
133+ awaitFrame(t, sends, ports.MsgOutput)
134+
135+ for i := range 12 {
136+ reads <- fmt.Appendf(nil, "line-%02d\r\n", i)
137+ }
138+ require.Eventually(t, func() bool {
139+ sess := firstSession(d)
140+ if sess == nil {
141+ return false
142+ }
143+ win := sess.activeTab()
144+ if win == nil {
145+ return false
146+ }
147+ win.mu.Lock()
148+ defer win.mu.Unlock()
149+ return len(scopy.NewSnapshot(win.scrollback, win.screen.Frame).Rows) >= 12
150+ }, 2*time.Second, 5*time.Millisecond)
151+
152+ sess := firstSession(d)
153+ require.NotNil(t, sess)
154+ ac := sess.client
155+ require.NotNil(t, ac)
156+ d.handleInput(sess, ac, []byte("\x1bu"))
157+ awaitFrame(t, sends, ports.MsgOutput)
158+ d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'})
159+
160+ var payload string
161+ require.Eventually(t, func() bool {
162+ out := awaitFrame(t, sends, ports.MsgOutput)
163+ msg, err := ports.UnmarshalOutput(out.Payload)
164+ require.NoError(t, err)
165+ payload = string(msg.Data)
166+ return strings.HasPrefix(payload, "\x1b]52;c;")
167+ }, 2*time.Second, 5*time.Millisecond)
168+ require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload)
169+ require.True(t, strings.HasSuffix(payload, "\a"), "OSC52 payload = %q", payload)
170+ decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a"))
171+ require.NoError(t, err)
172+ require.True(t, strings.HasPrefix(string(decoded), "line-00"), "decoded yank = %q", string(decoded))
173+
174+ releaseConn()
175+ closeOnce.Do(func() { close(readDone) })
176+ hg.Wait()
177+ d.sessWg.Wait()
178+}
179+
180+func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
181+ p, _ := newBlockingPTY(t)
182+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
183+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
184+ sess.tabs[0].screen.Write([]byte("live"))
185+
186+ d.enterCopyMode(sess, ac)
187+ awaitFrame(t, sends, ports.MsgOutput)
188+ d.handleInput(sess, ac, []byte("q"))
189+
190+ if ac.copyMode != nil {
191+ t.Fatal("copy mode still active after q")
192+ }
193+ out := awaitFrame(t, sends, ports.MsgOutput)
194+ msg, err := ports.UnmarshalOutput(out.Payload)
195+ require.NoError(t, err)
196+ if strings.Contains(string(msg.Data), "[COPY]") || !strings.Contains(string(msg.Data), "live") {
197+ t.Fatalf("exit repaint = %q, want live full repaint without copy status", string(msg.Data))
198+ }
199+}
200+
201+func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
202+ cases := []struct {
203+ name string
204+ input [][]byte
205+ wantCursor int
206+ }{
207+ {name: "escape then up arrow", input: [][]byte{[]byte("\x1b"), []byte("[A")}, wantCursor: 23},
208+ {name: "escape then down arrow", input: [][]byte{[]byte("g"), []byte("\x1b"), []byte("[B")}, wantCursor: 1},
209+ {name: "split up arrow", input: [][]byte{[]byte("\x1b["), []byte("A")}, wantCursor: 23},
210+ {name: "split page down", input: [][]byte{[]byte("g"), []byte("\x1b[6"), []byte("~")}, wantCursor: 23},
211+ }
212+ for _, tc := range cases {
213+ t.Run(tc.name, func(t *testing.T) {
214+ p, _ := newBlockingPTY(t)
215+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
216+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
217+ sess.tabs[0].scrollback.Append(testRow("old1 "))
218+ sess.tabs[0].scrollback.Append(testRow("old2 "))
219+ sess.tabs[0].screen.Write([]byte("live"))
220+
221+ d.enterCopyMode(sess, ac)
222+ awaitFrame(t, sends, ports.MsgOutput)
223+ for _, input := range tc.input {
224+ d.handleInput(sess, ac, input)
225+ }
226+
227+ require.NotNil(t, ac.copyMode)
228+ require.Equal(t, tc.wantCursor, ac.copyMode.Cursor)
229+ })
230+ }
231+}
232+
233+func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
234+ p, _ := newBlockingPTY(t)
235+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
236+ sess.tabs[0].scrollback = scopy.NewScrollback(1)
237+ longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)
238+ sess.tabs[0].screen.Frame = renderer.NewFrame(len(longLine), 1)
239+ copy(sess.tabs[0].screen.Frame.Row(0), testRow(longLine))
240+
241+ d.enterCopyMode(sess, ac)
242+ awaitFrame(t, sends, ports.MsgOutput)
243+ d.handleInput(sess, ac, []byte{' ', 'y'})
244+
245+ out := awaitFrame(t, sends, ports.MsgOutput)
246+ msg, err := ports.UnmarshalOutput(out.Payload)
247+ require.NoError(t, err)
248+ if strings.Contains(string(msg.Data), "\x1b]52;") {
249+ t.Fatalf("oversized yank emitted OSC52: %q", string(msg.Data))
250+ }
251+ if !strings.Contains(string(msg.Data), "selection too large to copy") {
252+ t.Fatalf("oversized yank repaint = %q, want too-large feedback", string(msg.Data))
253+ }
254+}
255+
256+func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
257+ p, _ := newBlockingPTY(t)
258+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
259+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
260+ d.clock = clk
261+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
262+ sess.tabs[0].screen.Write([]byte("live"))
263+
264+ d.enterCopyMode(sess, ac)
265+ awaitFrame(t, sends, ports.MsgOutput)
266+ d.handleInput(sess, ac, []byte("\x1b"))
267+ timer := <-clk.timers
268+ ac.copyMu.Lock()
269+ require.NotNil(t, ac.copyMode)
270+ ac.copyMu.Unlock()
271+ timer.ch <- time.Now()
272+ require.Eventually(t, func() bool {
273+ ac.copyMu.Lock()
274+ defer ac.copyMu.Unlock()
275+ return ac.copyMode == nil
276+ }, time.Second, 5*time.Millisecond)
277+}
278+
279+func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
280+ p, _ := newBlockingPTY(t)
281+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
282+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
283+ d.clock = clk
284+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
285+ sess.tabs[0].screen.Write([]byte("live"))
286+
287+ d.enterCopyMode(sess, ac)
288+ awaitFrame(t, sends, ports.MsgOutput)
289+ d.handleInput(sess, ac, []byte("\x1b"))
290+ timer := <-clk.timers
291+ d.enterCopyMode(sess, ac)
292+ awaitFrame(t, sends, ports.MsgOutput)
293+
294+ timer.ch <- time.Now()
295+ require.Never(t, func() bool {
296+ ac.copyMu.Lock()
297+ defer ac.copyMu.Unlock()
298+ return ac.copyMode == nil
299+ }, 50*time.Millisecond, 5*time.Millisecond)
300+ ac.copyMu.Lock()
301+ require.Nil(t, ac.copyESC.timer)
302+ require.Nil(t, ac.copyESC.done)
303+ require.Empty(t, ac.copyPending)
304+ ac.copyMu.Unlock()
305+}
306+
307+func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
308+ cases := []struct {
309+ name string
310+ input []byte
311+ }{
312+ {name: "yank", input: []byte("y")},
313+ {name: "enter", input: []byte("\r")},
314+ }
315+ for _, tc := range cases {
316+ t.Run(tc.name, func(t *testing.T) {
317+ p, _ := newBlockingPTY(t)
318+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
319+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
320+ sess.tabs[0].screen.Write([]byte("live"))
321+
322+ d.enterCopyMode(sess, ac)
323+ awaitFrame(t, sends, ports.MsgOutput)
324+ d.handleInput(sess, ac, tc.input)
325+
326+ if ac.copyMode != nil {
327+ t.Fatal("copy mode still active after empty yank")
328+ }
329+ out := awaitFrame(t, sends, ports.MsgOutput)
330+ msg, err := ports.UnmarshalOutput(out.Payload)
331+ require.NoError(t, err)
332+ if strings.Contains(string(msg.Data), "\x1b]52;") {
333+ t.Fatalf("empty yank emitted OSC52 clipboard clear: %q", string(msg.Data))
334+ }
335+ if strings.Contains(string(msg.Data), "[COPY]") || !strings.Contains(string(msg.Data), "live") {
336+ t.Fatalf("empty yank repaint = %q, want live full repaint", string(msg.Data))
337+ }
338+ })
339+ }
340+}
341+
342+func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
343+ p, _ := newBlockingPTY(t)
344+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
345+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
346+ sess.tabs[0].screen.Write([]byte("live"))
347+
348+ done := make(chan struct{})
349+ var drain sync.WaitGroup
350+ drain.Go(func() {
351+ for {
352+ select {
353+ case <-sends:
354+ case <-done:
355+ return
356+ }
357+ }
358+ })
359+
360+ var wg sync.WaitGroup
361+ for range 100 {
362+ wg.Go(func() {
363+ d.enterCopyMode(sess, ac)
364+ d.handleInput(sess, ac, []byte("q"))
365+ })
366+ wg.Go(func() {
367+ d.paint(sess, ac, true)
368+ })
369+ }
370+ wg.Wait()
371+ close(done)
372+ drain.Wait()
373+}
374+
375+func TestCursorTailHidesInCopyMode(t *testing.T) {
376+ p, _ := newBlockingPTY(t)
377+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
378+ sess.tabs[0].screen.Write([]byte("live"))
379+
380+ d.enterCopyMode(sess, ac)
381+ data := mustOutputData(t, sends)
382+ require.Contains(t, string(data), "\x1b[?25l")
383+}
M internal/usecase/daemon/daemon_test.go +21 -2062 large diff (2083 lines changed): collapsed
A internal/usecase/daemon/input_test.go +259 -0
@@ -0,0 +1,259 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+// --- test doubles -----------------------------------------------------------
15+
16+// stubClock returns timers whose channel never fires, so a scheduler under it
17+// blocks in its debounce loop until the session context is cancelled. Used by
18+
19+func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
20+ writes1 := make(chan []byte, 1)
21+ writes2 := make(chan []byte, 1)
22+ writes3 := make(chan []byte, 1)
23+ p1, releasePTY1 := newBlockingPTYWithWrites(t, writes1)
24+ p2, releasePTY2 := newBlockingPTYWithWrites(t, writes2)
25+ p3, releasePTY3 := newBlockingPTYWithWrites(t, writes3)
26+ d := newTestDaemon(t, newFactorySeq(t, p1, p2, p3), stubClock{})
27+ tr, sends, releaseConn := newConn(t,
28+ mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}),
29+ frameInput([]byte("\x1bc")),
30+ frameInput([]byte("\x1bc")),
31+ frameInput([]byte("\x1b1")),
32+ frameInput([]byte("A")),
33+ frameInput([]byte("\x1b2")),
34+ frameInput([]byte("B")),
35+ frameInput([]byte("\x1b3")),
36+ frameInput([]byte("C")),
37+ )
38+
39+ var hg sync.WaitGroup
40+ hg.Go(func() { d.handleConn(tr) })
41+ awaitFrame(t, sends, ports.MsgWelcome)
42+ awaitFrame(t, sends, ports.MsgOutput)
43+
44+ require.Eventually(t, func() bool {
45+ sessions := listSessions(t, d)
46+ return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 3
47+ }, 2*time.Second, 5*time.Millisecond)
48+ require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond)
49+ require.Equal(t, []byte("A"), <-writes1)
50+ require.Eventually(t, func() bool { return len(writes2) == 1 }, 2*time.Second, 5*time.Millisecond)
51+ require.Equal(t, []byte("B"), <-writes2)
52+ require.Eventually(t, func() bool { return len(writes3) == 1 }, 2*time.Second, 5*time.Millisecond)
53+ require.Equal(t, []byte("C"), <-writes3)
54+
55+ releaseConn()
56+ releasePTY1()
57+ releasePTY2()
58+ releasePTY3()
59+ hg.Wait()
60+ d.sessWg.Wait()
61+}
62+
63+func TestAltCCreatesSecondActiveTab(t *testing.T) {
64+ p1, releasePTY1 := newBlockingPTY(t)
65+ p2, releasePTY2 := newBlockingPTY(t)
66+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
67+ tr, sends, releaseConn := newConn(t,
68+ mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}),
69+ frameInput([]byte("\x1bc")),
70+ )
71+
72+ var hg sync.WaitGroup
73+ hg.Go(func() { d.handleConn(tr) })
74+ awaitFrame(t, sends, ports.MsgWelcome)
75+ awaitFrame(t, sends, ports.MsgOutput)
76+
77+ require.Eventually(t, func() bool {
78+ sessions := listSessions(t, d)
79+ return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 2
80+ }, 2*time.Second, 5*time.Millisecond)
81+
82+ releaseConn()
83+ releasePTY1()
84+ releasePTY2()
85+ hg.Wait()
86+ d.sessWg.Wait()
87+}
88+
89+func TestAltNextPreviousSwitchActiveTab(t *testing.T) {
90+ cases := []struct {
91+ name string
92+ start int
93+ input []byte
94+ wantIndex int
95+ }{
96+ {name: "next advances", start: 0, input: []byte("\x1bn"), wantIndex: 1},
97+ {name: "next wraps", start: 2, input: []byte("\x1bn"), wantIndex: 0},
98+ {name: "previous moves back", start: 2, input: []byte("\x1bp"), wantIndex: 1},
99+ {name: "previous wraps", start: 0, input: []byte("\x1bp"), wantIndex: 2},
100+ }
101+
102+ for _, tc := range cases {
103+ t.Run(tc.name, func(t *testing.T) {
104+ d, sess, ac, _, releases := newManualTabSession(t, 3)
105+ defer func() {
106+ for _, release := range releases {
107+ release()
108+ }
109+ }()
110+ sess.active = tc.start
111+
112+ d.handleInput(sess, ac, tc.input)
113+
114+ require.Equal(t, tc.wantIndex, activeTabIndex(sess))
115+ })
116+ }
117+}
118+
119+func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {
120+ writes := make(chan []byte, 1)
121+ p1, releasePTY1 := newBlockingPTY(t)
122+ p2, releasePTY2 := newBlockingPTY(t)
123+ p3, releasePTY3 := newBlockingPTYWithWrites(t, writes)
124+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2, p3)
125+ sess.active = 1
126+
127+ d.handleInput(sess, ac, []byte("\x1bx"))
128+
129+ require.Equal(t, 1, sessionCount(d))
130+ require.Len(t, sess.tabs, 2)
131+ require.Equal(t, 1, activeTabIndex(sess), "closing middle tab selects the next remaining tab")
132+ d.handleInput(sess, ac, []byte("Z"))
133+ require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond)
134+ require.Equal(t, []byte("Z"), <-writes)
135+
136+ releasePTY1()
137+ releasePTY2()
138+ releasePTY3()
139+}
140+
141+func TestAltDDetachesCurrentClient(t *testing.T) {
142+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
143+ defer releases[0]()
144+
145+ d.handleInput(sess, ac, []byte("\x1bd"))
146+
147+ require.Nil(t, sess.client)
148+ f := awaitFrame(t, sends, ports.MsgDetached)
149+ det, err := ports.UnmarshalDetached(f.Payload)
150+ require.NoError(t, err)
151+ require.Equal(t, ports.ReasonDetach, det.Reason)
152+}
153+
154+func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) {
155+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
156+ defer releases[0]()
157+ sess.ephemeral = true
158+ sess.name = "0"
159+
160+ d.handleInput(sess, ac, []byte("\x1br"))
161+
162+ require.False(t, sess.ephemeral)
163+ require.Equal(t, "0", sess.name)
164+ awaitFrame(t, sends, ports.MsgOutput)
165+}
166+
167+func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
168+ writes := make(chan []byte, 4)
169+ p, _ := newBlockingPTYWithWrites(t, writes)
170+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
171+ win := sess.tabs[0]
172+ win.screen.Write([]byte("live"))
173+
174+ d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
175+ data := mustOutputData(t, sends)
176+ require.NotNil(t, ac.copyMode)
177+ require.Equal(t, 19, ac.copyMode.Cursor)
178+ require.Contains(t, string(data), "[SCROLL]")
179+
180+ d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
181+ mustOutputData(t, sends)
182+ require.Nil(t, ac.copyMode)
183+
184+ d.handleInput(sess, ac, []byte("\x1b[<64;1;1Mq"))
185+ mustOutputData(t, sends)
186+ mustOutputData(t, sends)
187+ require.Nil(t, ac.copyMode, "q after wheel in same input must be routed after copy mode is entered")
188+ select {
189+ case got := <-writes:
190+ t.Fatalf("mouse/copy input forwarded to PTY: %q", got)
191+ default:
192+ }
193+}
194+
195+func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
196+ writes := make(chan []byte, 2)
197+ p, _ := newBlockingPTYWithWrites(t, writes)
198+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
199+ sess.tabs[0].screen.Write([]byte("\x1b[?1049h"))
200+
201+ d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
202+ d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
203+
204+ require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes)
205+ require.Equal(t, []byte("\x1b[B\x1b[B\x1b[B"), <-writes)
206+ require.Nil(t, ac.copyMode)
207+}
208+
209+func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
210+ writes := make(chan []byte, 4)
211+ p, _ := newBlockingPTYWithWrites(t, writes)
212+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
213+
214+ d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
215+ select {
216+ case got := <-writes:
217+ t.Fatalf("press without child mouse mode forwarded: %q", got)
218+ default:
219+ }
220+
221+ sess.tabs[0].screen.Write([]byte("\x1b[?1000h"))
222+ raw := []byte("\x1b[<0;2;3M")
223+ d.handleInput(sess, ac, raw)
224+ select {
225+ case got := <-writes:
226+ t.Fatalf("SGR report forwarded to child without SGR mode: %q", got)
227+ default:
228+ }
229+
230+ sess.tabs[0].screen.Write([]byte("\x1b[?1006h"))
231+ d.handleInput(sess, ac, raw)
232+ require.Equal(t, raw, <-writes)
233+
234+ d.handleInput(sess, ac, []byte("\x1b[<0;1;24M"))
235+ select {
236+ case got := <-writes:
237+ t.Fatalf("status-row mouse report forwarded: %q", got)
238+ default:
239+ }
240+}
241+
242+func TestMouseSplitReportPreservesOrder(t *testing.T) {
243+ writes := make(chan []byte, 2)
244+ p, _ := newBlockingPTYWithWrites(t, writes)
245+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
246+ sess.tabs[0].screen.Write([]byte("live"))
247+
248+ d.handleInput(sess, ac, []byte("\x1b[<64;"))
249+ d.handleInput(sess, ac, []byte("1;1Mq"))
250+
251+ mustOutputData(t, sends)
252+ mustOutputData(t, sends)
253+ require.Nil(t, ac.copyMode)
254+ select {
255+ case got := <-writes:
256+ t.Fatalf("split mouse/copy bytes forwarded to PTY: %q", got)
257+ default:
258+ }
259+}
@@ -0,0 +1,259 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+// --- test doubles -----------------------------------------------------------
15+
16+// stubClock returns timers whose channel never fires, so a scheduler under it
17+// blocks in its debounce loop until the session context is cancelled. Used by
18+
19+func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
20+ writes1 := make(chan []byte, 1)
21+ writes2 := make(chan []byte, 1)
22+ writes3 := make(chan []byte, 1)
23+ p1, releasePTY1 := newBlockingPTYWithWrites(t, writes1)
24+ p2, releasePTY2 := newBlockingPTYWithWrites(t, writes2)
25+ p3, releasePTY3 := newBlockingPTYWithWrites(t, writes3)
26+ d := newTestDaemon(t, newFactorySeq(t, p1, p2, p3), stubClock{})
27+ tr, sends, releaseConn := newConn(t,
28+ mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}),
29+ frameInput([]byte("\x1bc")),
30+ frameInput([]byte("\x1bc")),
31+ frameInput([]byte("\x1b1")),
32+ frameInput([]byte("A")),
33+ frameInput([]byte("\x1b2")),
34+ frameInput([]byte("B")),
35+ frameInput([]byte("\x1b3")),
36+ frameInput([]byte("C")),
37+ )
38+
39+ var hg sync.WaitGroup
40+ hg.Go(func() { d.handleConn(tr) })
41+ awaitFrame(t, sends, ports.MsgWelcome)
42+ awaitFrame(t, sends, ports.MsgOutput)
43+
44+ require.Eventually(t, func() bool {
45+ sessions := listSessions(t, d)
46+ return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 3
47+ }, 2*time.Second, 5*time.Millisecond)
48+ require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond)
49+ require.Equal(t, []byte("A"), <-writes1)
50+ require.Eventually(t, func() bool { return len(writes2) == 1 }, 2*time.Second, 5*time.Millisecond)
51+ require.Equal(t, []byte("B"), <-writes2)
52+ require.Eventually(t, func() bool { return len(writes3) == 1 }, 2*time.Second, 5*time.Millisecond)
53+ require.Equal(t, []byte("C"), <-writes3)
54+
55+ releaseConn()
56+ releasePTY1()
57+ releasePTY2()
58+ releasePTY3()
59+ hg.Wait()
60+ d.sessWg.Wait()
61+}
62+
63+func TestAltCCreatesSecondActiveTab(t *testing.T) {
64+ p1, releasePTY1 := newBlockingPTY(t)
65+ p2, releasePTY2 := newBlockingPTY(t)
66+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
67+ tr, sends, releaseConn := newConn(t,
68+ mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}),
69+ frameInput([]byte("\x1bc")),
70+ )
71+
72+ var hg sync.WaitGroup
73+ hg.Go(func() { d.handleConn(tr) })
74+ awaitFrame(t, sends, ports.MsgWelcome)
75+ awaitFrame(t, sends, ports.MsgOutput)
76+
77+ require.Eventually(t, func() bool {
78+ sessions := listSessions(t, d)
79+ return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 2
80+ }, 2*time.Second, 5*time.Millisecond)
81+
82+ releaseConn()
83+ releasePTY1()
84+ releasePTY2()
85+ hg.Wait()
86+ d.sessWg.Wait()
87+}
88+
89+func TestAltNextPreviousSwitchActiveTab(t *testing.T) {
90+ cases := []struct {
91+ name string
92+ start int
93+ input []byte
94+ wantIndex int
95+ }{
96+ {name: "next advances", start: 0, input: []byte("\x1bn"), wantIndex: 1},
97+ {name: "next wraps", start: 2, input: []byte("\x1bn"), wantIndex: 0},
98+ {name: "previous moves back", start: 2, input: []byte("\x1bp"), wantIndex: 1},
99+ {name: "previous wraps", start: 0, input: []byte("\x1bp"), wantIndex: 2},
100+ }
101+
102+ for _, tc := range cases {
103+ t.Run(tc.name, func(t *testing.T) {
104+ d, sess, ac, _, releases := newManualTabSession(t, 3)
105+ defer func() {
106+ for _, release := range releases {
107+ release()
108+ }
109+ }()
110+ sess.active = tc.start
111+
112+ d.handleInput(sess, ac, tc.input)
113+
114+ require.Equal(t, tc.wantIndex, activeTabIndex(sess))
115+ })
116+ }
117+}
118+
119+func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {
120+ writes := make(chan []byte, 1)
121+ p1, releasePTY1 := newBlockingPTY(t)
122+ p2, releasePTY2 := newBlockingPTY(t)
123+ p3, releasePTY3 := newBlockingPTYWithWrites(t, writes)
124+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2, p3)
125+ sess.active = 1
126+
127+ d.handleInput(sess, ac, []byte("\x1bx"))
128+
129+ require.Equal(t, 1, sessionCount(d))
130+ require.Len(t, sess.tabs, 2)
131+ require.Equal(t, 1, activeTabIndex(sess), "closing middle tab selects the next remaining tab")
132+ d.handleInput(sess, ac, []byte("Z"))
133+ require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond)
134+ require.Equal(t, []byte("Z"), <-writes)
135+
136+ releasePTY1()
137+ releasePTY2()
138+ releasePTY3()
139+}
140+
141+func TestAltDDetachesCurrentClient(t *testing.T) {
142+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
143+ defer releases[0]()
144+
145+ d.handleInput(sess, ac, []byte("\x1bd"))
146+
147+ require.Nil(t, sess.client)
148+ f := awaitFrame(t, sends, ports.MsgDetached)
149+ det, err := ports.UnmarshalDetached(f.Payload)
150+ require.NoError(t, err)
151+ require.Equal(t, ports.ReasonDetach, det.Reason)
152+}
153+
154+func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) {
155+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
156+ defer releases[0]()
157+ sess.ephemeral = true
158+ sess.name = "0"
159+
160+ d.handleInput(sess, ac, []byte("\x1br"))
161+
162+ require.False(t, sess.ephemeral)
163+ require.Equal(t, "0", sess.name)
164+ awaitFrame(t, sends, ports.MsgOutput)
165+}
166+
167+func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
168+ writes := make(chan []byte, 4)
169+ p, _ := newBlockingPTYWithWrites(t, writes)
170+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
171+ win := sess.tabs[0]
172+ win.screen.Write([]byte("live"))
173+
174+ d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
175+ data := mustOutputData(t, sends)
176+ require.NotNil(t, ac.copyMode)
177+ require.Equal(t, 19, ac.copyMode.Cursor)
178+ require.Contains(t, string(data), "[SCROLL]")
179+
180+ d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
181+ mustOutputData(t, sends)
182+ require.Nil(t, ac.copyMode)
183+
184+ d.handleInput(sess, ac, []byte("\x1b[<64;1;1Mq"))
185+ mustOutputData(t, sends)
186+ mustOutputData(t, sends)
187+ require.Nil(t, ac.copyMode, "q after wheel in same input must be routed after copy mode is entered")
188+ select {
189+ case got := <-writes:
190+ t.Fatalf("mouse/copy input forwarded to PTY: %q", got)
191+ default:
192+ }
193+}
194+
195+func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
196+ writes := make(chan []byte, 2)
197+ p, _ := newBlockingPTYWithWrites(t, writes)
198+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
199+ sess.tabs[0].screen.Write([]byte("\x1b[?1049h"))
200+
201+ d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
202+ d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
203+
204+ require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes)
205+ require.Equal(t, []byte("\x1b[B\x1b[B\x1b[B"), <-writes)
206+ require.Nil(t, ac.copyMode)
207+}
208+
209+func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
210+ writes := make(chan []byte, 4)
211+ p, _ := newBlockingPTYWithWrites(t, writes)
212+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
213+
214+ d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
215+ select {
216+ case got := <-writes:
217+ t.Fatalf("press without child mouse mode forwarded: %q", got)
218+ default:
219+ }
220+
221+ sess.tabs[0].screen.Write([]byte("\x1b[?1000h"))
222+ raw := []byte("\x1b[<0;2;3M")
223+ d.handleInput(sess, ac, raw)
224+ select {
225+ case got := <-writes:
226+ t.Fatalf("SGR report forwarded to child without SGR mode: %q", got)
227+ default:
228+ }
229+
230+ sess.tabs[0].screen.Write([]byte("\x1b[?1006h"))
231+ d.handleInput(sess, ac, raw)
232+ require.Equal(t, raw, <-writes)
233+
234+ d.handleInput(sess, ac, []byte("\x1b[<0;1;24M"))
235+ select {
236+ case got := <-writes:
237+ t.Fatalf("status-row mouse report forwarded: %q", got)
238+ default:
239+ }
240+}
241+
242+func TestMouseSplitReportPreservesOrder(t *testing.T) {
243+ writes := make(chan []byte, 2)
244+ p, _ := newBlockingPTYWithWrites(t, writes)
245+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
246+ sess.tabs[0].screen.Write([]byte("live"))
247+
248+ d.handleInput(sess, ac, []byte("\x1b[<64;"))
249+ d.handleInput(sess, ac, []byte("1;1Mq"))
250+
251+ mustOutputData(t, sends)
252+ mustOutputData(t, sends)
253+ require.Nil(t, ac.copyMode)
254+ select {
255+ case got := <-writes:
256+ t.Fatalf("split mouse/copy bytes forwarded to PTY: %q", got)
257+ default:
258+ }
259+}
A internal/usecase/daemon/picker_test.go +283 -0
@@ -0,0 +1,283 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/usecase/keys"
14+ "github.com/bnema/vev/pkg/renderer"
15+ "github.com/bnema/vev/pkg/vt"
16+)
17+
18+// --- test doubles -----------------------------------------------------------
19+
20+// stubClock returns timers whose channel never fires, so a scheduler under it
21+// blocks in its debounce loop until the session context is cancelled. Used by
22+
23+func TestAltTOpensPickerModalAndDoesNotForward(t *testing.T) {
24+ writes := make(chan []byte, 1)
25+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
26+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
27+ defer releasePTY()
28+
29+ d.handleInput(sess, ac, []byte("\x1bt"))
30+
31+ require.True(t, ac.pickerActive())
32+ select {
33+ case got := <-writes:
34+ t.Fatalf("picker binding forwarded to PTY: %q", got)
35+ default:
36+ }
37+ out := awaitFrame(t, sends, ports.MsgOutput)
38+ msg, err := ports.UnmarshalOutput(out.Payload)
39+ require.NoError(t, err)
40+ data := string(msg.Data)
41+ require.Contains(t, data, "┌")
42+ require.Contains(t, data, "Sessions")
43+}
44+
45+func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
46+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
47+ defer func() {
48+ for _, release := range releases {
49+ release()
50+ }
51+ }()
52+
53+ d.handleInput(sess, ac, []byte("\x1bt"))
54+ awaitFrame(t, sends, ports.MsgOutput)
55+ d.handleInput(sess, ac, []byte("j"))
56+ awaitFrame(t, sends, ports.MsgOutput)
57+ d.handleInput(sess, ac, []byte("\r"))
58+
59+ require.Equal(t, 1, activeTabIndex(sess))
60+ awaitFrame(t, sends, ports.MsgOutput)
61+ d.handleInput(sess, ac, []byte("\x1bt"))
62+ awaitFrame(t, sends, ports.MsgOutput)
63+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
64+ d.clock = clk
65+ d.handleInput(sess, ac, []byte("\x1b"))
66+ timer := <-clk.timers
67+ require.True(t, ac.pickerActive())
68+ timer.ch <- time.Now()
69+ require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond)
70+ awaitFrame(t, sends, ports.MsgOutput)
71+}
72+
73+func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
74+ cases := []struct {
75+ name string
76+ input [][]byte
77+ wantActive int
78+ }{
79+ {name: "escape then down arrow", input: [][]byte{[]byte("\x1b"), []byte("[B")}, wantActive: 1},
80+ {name: "escape then up arrow", input: [][]byte{[]byte("j"), []byte("\x1b"), []byte("[A")}, wantActive: 0},
81+ {name: "split down arrow", input: [][]byte{[]byte("\x1b["), []byte("B")}, wantActive: 1},
82+ }
83+ for _, tc := range cases {
84+ t.Run(tc.name, func(t *testing.T) {
85+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
86+ defer func() {
87+ for _, release := range releases {
88+ release()
89+ }
90+ }()
91+
92+ d.handleInput(sess, ac, []byte("\x1bt"))
93+ awaitFrame(t, sends, ports.MsgOutput)
94+ for _, input := range tc.input {
95+ d.handleInput(sess, ac, input)
96+ }
97+ require.True(t, ac.pickerActive())
98+ d.handleInput(sess, ac, []byte("\r"))
99+ require.Equal(t, tc.wantActive, activeTabIndex(sess))
100+ })
101+ }
102+}
103+
104+func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
105+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
106+ defer releases[0]()
107+
108+ d.handleInput(sess, ac, []byte("\x1bt"))
109+ awaitFrame(t, sends, ports.MsgOutput)
110+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
111+ d.clock = clk
112+ d.handleInput(sess, ac, []byte("\x1b"))
113+ timer := <-clk.timers
114+ require.True(t, ac.pickerActive())
115+ timer.ch <- time.Now()
116+ require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond)
117+}
118+
119+func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
120+ p1, releasePTY1 := newBlockingPTY(t)
121+ p2, releasePTY2 := newBlockingPTY(t)
122+ defer releasePTY1()
123+ defer releasePTY2()
124+ d := newTestDaemon(t, nil, stubClock{})
125+ tr1, sends1 := newCapturingTransport(t)
126+ tr2, sends2 := newCapturingTransport(t)
127+ ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
128+ ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
129+ sctx1, cancel1 := context.WithCancel(d.serveCtx)
130+ sctx2, cancel2 := context.WithCancel(d.serveCtx)
131+ defer cancel1()
132+ defer cancel2()
133+ sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1}
134+ sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2}
135+ ac1.setSession(sess1)
136+ ac2.setSession(sess2)
137+ ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
138+ ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2})
139+ d.sessions[sess1.id] = sess1
140+ d.sessions[sess2.id] = sess2
141+
142+ d.handleInput(sess1, ac1, []byte("\x1bt"))
143+ awaitFrame(t, sends1, ports.MsgOutput)
144+ d.handleInput(sess1, ac1, []byte("j"))
145+ awaitFrame(t, sends1, ports.MsgOutput)
146+ d.handleInput(sess1, ac1, []byte("\r"))
147+
148+ require.Same(t, sess2, ac1.currentSession())
149+ require.Same(t, ac1, sess2.client)
150+ require.Nil(t, sess1.client)
151+ require.Equal(t, 2, sessionCount(d), "old ephemeral session remains alive after picker switch")
152+ det := awaitFrame(t, sends2, ports.MsgDetached)
153+ dm, err := ports.UnmarshalDetached(det.Payload)
154+ require.NoError(t, err)
155+ require.Equal(t, ports.ReasonDetach, dm.Reason)
156+ awaitFrame(t, sends1, ports.MsgOutput)
157+}
158+
159+func TestPickerLivePreviewRepaintsInactiveTab(t *testing.T) {
160+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
161+ defer func() {
162+ for _, release := range releases {
163+ release()
164+ }
165+ }()
166+
167+ d.handleInput(sess, ac, []byte("\x1bt"))
168+ awaitFrame(t, sends, ports.MsgOutput)
169+ d.handleInput(sess, ac, []byte("j"))
170+ awaitFrame(t, sends, ports.MsgOutput)
171+
172+ previewTab := sess.tabs[1]
173+ previewTab.mu.Lock()
174+ previewTab.screen.Write([]byte("inactive-preview-live"))
175+ previewTab.mu.Unlock()
176+ d.render(sess, previewTab)
177+
178+ previewOut := awaitFrame(t, sends, ports.MsgOutput)
179+ previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
180+ require.NoError(t, err)
181+ require.Contains(t, string(previewMsg.Data), "inactive-preview-live")
182+}
183+
184+func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
185+ p1, releasePTY1 := newBlockingPTY(t)
186+ p2, releasePTY2 := newBlockingPTY(t)
187+ defer releasePTY1()
188+ defer releasePTY2()
189+ d := newTestDaemon(t, nil, stubClock{})
190+ tr1, sends1 := newCapturingTransport(t)
191+ tr2, _ := newCapturingTransport(t)
192+ ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
193+ ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
194+ sctx1, cancel1 := context.WithCancel(d.serveCtx)
195+ sctx2, cancel2 := context.WithCancel(d.serveCtx)
196+ defer cancel1()
197+ defer cancel2()
198+ sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1}
199+ sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2}
200+ ac1.setSession(sess1)
201+ ac2.setSession(sess2)
202+ ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
203+ ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2})
204+ d.sessions[sess1.id] = sess1
205+ d.sessions[sess2.id] = sess2
206+
207+ d.handleInput(sess1, ac1, []byte("\x1bt"))
208+ awaitFrame(t, sends1, ports.MsgOutput)
209+ d.handleInput(sess1, ac1, []byte("j"))
210+ awaitFrame(t, sends1, ports.MsgOutput)
211+
212+ previewTab := sess2.tabs[0]
213+ previewTab.mu.Lock()
214+ previewTab.screen.Write([]byte("cross-session-preview-live"))
215+ previewTab.mu.Unlock()
216+ d.render(sess2, previewTab)
217+
218+ previewOut := awaitFrame(t, sends1, ports.MsgOutput)
219+ previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
220+ require.NoError(t, err)
221+ require.Contains(t, string(previewMsg.Data), "cross-session-preview-live")
222+}
223+
224+func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) {
225+ d, sess, ac, sends, releases := newManualTabSession(t, 3)
226+ defer func() {
227+ for _, release := range releases {
228+ release()
229+ }
230+ }()
231+
232+ done := make(chan struct{})
233+ var drain sync.WaitGroup
234+ drain.Go(func() {
235+ for {
236+ select {
237+ case <-sends:
238+ case <-done:
239+ return
240+ }
241+ }
242+ })
243+
244+ var wg sync.WaitGroup
245+ for i := range 100 {
246+ wg.Go(func() {
247+ d.enterPicker(sess, ac)
248+ })
249+ wg.Go(func() {
250+ d.handlePickerInput(ac, []byte("j"))
251+ d.handlePickerInput(ac, []byte("k"))
252+ })
253+ wg.Go(func() {
254+ d.closePicker(ac)
255+ })
256+ tb := sess.tabs[i%len(sess.tabs)]
257+ wg.Go(func() {
258+ tb.mu.Lock()
259+ tb.screen.Write([]byte("render-race"))
260+ tb.mu.Unlock()
261+ d.render(sess, tb)
262+ })
263+ }
264+ wg.Wait()
265+ close(done)
266+ drain.Wait()
267+}
268+
269+func TestPickerResizeRecomposesModal(t *testing.T) {
270+ p, releasePTY := newBlockingPTY(t)
271+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
272+ defer releasePTY()
273+
274+ d.handleInput(sess, ac, []byte("\x1bt"))
275+ awaitFrame(t, sends, ports.MsgOutput)
276+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
277+
278+ out := awaitFrame(t, sends, ports.MsgOutput)
279+ msg, err := ports.UnmarshalOutput(out.Payload)
280+ require.NoError(t, err)
281+ require.Contains(t, string(msg.Data), "┌")
282+ require.Contains(t, string(msg.Data), "Sessions")
283+}
@@ -0,0 +1,283 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/usecase/keys"
14+ "github.com/bnema/vev/pkg/renderer"
15+ "github.com/bnema/vev/pkg/vt"
16+)
17+
18+// --- test doubles -----------------------------------------------------------
19+
20+// stubClock returns timers whose channel never fires, so a scheduler under it
21+// blocks in its debounce loop until the session context is cancelled. Used by
22+
23+func TestAltTOpensPickerModalAndDoesNotForward(t *testing.T) {
24+ writes := make(chan []byte, 1)
25+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
26+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
27+ defer releasePTY()
28+
29+ d.handleInput(sess, ac, []byte("\x1bt"))
30+
31+ require.True(t, ac.pickerActive())
32+ select {
33+ case got := <-writes:
34+ t.Fatalf("picker binding forwarded to PTY: %q", got)
35+ default:
36+ }
37+ out := awaitFrame(t, sends, ports.MsgOutput)
38+ msg, err := ports.UnmarshalOutput(out.Payload)
39+ require.NoError(t, err)
40+ data := string(msg.Data)
41+ require.Contains(t, data, "┌")
42+ require.Contains(t, data, "Sessions")
43+}
44+
45+func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
46+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
47+ defer func() {
48+ for _, release := range releases {
49+ release()
50+ }
51+ }()
52+
53+ d.handleInput(sess, ac, []byte("\x1bt"))
54+ awaitFrame(t, sends, ports.MsgOutput)
55+ d.handleInput(sess, ac, []byte("j"))
56+ awaitFrame(t, sends, ports.MsgOutput)
57+ d.handleInput(sess, ac, []byte("\r"))
58+
59+ require.Equal(t, 1, activeTabIndex(sess))
60+ awaitFrame(t, sends, ports.MsgOutput)
61+ d.handleInput(sess, ac, []byte("\x1bt"))
62+ awaitFrame(t, sends, ports.MsgOutput)
63+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
64+ d.clock = clk
65+ d.handleInput(sess, ac, []byte("\x1b"))
66+ timer := <-clk.timers
67+ require.True(t, ac.pickerActive())
68+ timer.ch <- time.Now()
69+ require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond)
70+ awaitFrame(t, sends, ports.MsgOutput)
71+}
72+
73+func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
74+ cases := []struct {
75+ name string
76+ input [][]byte
77+ wantActive int
78+ }{
79+ {name: "escape then down arrow", input: [][]byte{[]byte("\x1b"), []byte("[B")}, wantActive: 1},
80+ {name: "escape then up arrow", input: [][]byte{[]byte("j"), []byte("\x1b"), []byte("[A")}, wantActive: 0},
81+ {name: "split down arrow", input: [][]byte{[]byte("\x1b["), []byte("B")}, wantActive: 1},
82+ }
83+ for _, tc := range cases {
84+ t.Run(tc.name, func(t *testing.T) {
85+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
86+ defer func() {
87+ for _, release := range releases {
88+ release()
89+ }
90+ }()
91+
92+ d.handleInput(sess, ac, []byte("\x1bt"))
93+ awaitFrame(t, sends, ports.MsgOutput)
94+ for _, input := range tc.input {
95+ d.handleInput(sess, ac, input)
96+ }
97+ require.True(t, ac.pickerActive())
98+ d.handleInput(sess, ac, []byte("\r"))
99+ require.Equal(t, tc.wantActive, activeTabIndex(sess))
100+ })
101+ }
102+}
103+
104+func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
105+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
106+ defer releases[0]()
107+
108+ d.handleInput(sess, ac, []byte("\x1bt"))
109+ awaitFrame(t, sends, ports.MsgOutput)
110+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
111+ d.clock = clk
112+ d.handleInput(sess, ac, []byte("\x1b"))
113+ timer := <-clk.timers
114+ require.True(t, ac.pickerActive())
115+ timer.ch <- time.Now()
116+ require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond)
117+}
118+
119+func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
120+ p1, releasePTY1 := newBlockingPTY(t)
121+ p2, releasePTY2 := newBlockingPTY(t)
122+ defer releasePTY1()
123+ defer releasePTY2()
124+ d := newTestDaemon(t, nil, stubClock{})
125+ tr1, sends1 := newCapturingTransport(t)
126+ tr2, sends2 := newCapturingTransport(t)
127+ ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
128+ ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
129+ sctx1, cancel1 := context.WithCancel(d.serveCtx)
130+ sctx2, cancel2 := context.WithCancel(d.serveCtx)
131+ defer cancel1()
132+ defer cancel2()
133+ sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1}
134+ sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2}
135+ ac1.setSession(sess1)
136+ ac2.setSession(sess2)
137+ ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
138+ ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2})
139+ d.sessions[sess1.id] = sess1
140+ d.sessions[sess2.id] = sess2
141+
142+ d.handleInput(sess1, ac1, []byte("\x1bt"))
143+ awaitFrame(t, sends1, ports.MsgOutput)
144+ d.handleInput(sess1, ac1, []byte("j"))
145+ awaitFrame(t, sends1, ports.MsgOutput)
146+ d.handleInput(sess1, ac1, []byte("\r"))
147+
148+ require.Same(t, sess2, ac1.currentSession())
149+ require.Same(t, ac1, sess2.client)
150+ require.Nil(t, sess1.client)
151+ require.Equal(t, 2, sessionCount(d), "old ephemeral session remains alive after picker switch")
152+ det := awaitFrame(t, sends2, ports.MsgDetached)
153+ dm, err := ports.UnmarshalDetached(det.Payload)
154+ require.NoError(t, err)
155+ require.Equal(t, ports.ReasonDetach, dm.Reason)
156+ awaitFrame(t, sends1, ports.MsgOutput)
157+}
158+
159+func TestPickerLivePreviewRepaintsInactiveTab(t *testing.T) {
160+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
161+ defer func() {
162+ for _, release := range releases {
163+ release()
164+ }
165+ }()
166+
167+ d.handleInput(sess, ac, []byte("\x1bt"))
168+ awaitFrame(t, sends, ports.MsgOutput)
169+ d.handleInput(sess, ac, []byte("j"))
170+ awaitFrame(t, sends, ports.MsgOutput)
171+
172+ previewTab := sess.tabs[1]
173+ previewTab.mu.Lock()
174+ previewTab.screen.Write([]byte("inactive-preview-live"))
175+ previewTab.mu.Unlock()
176+ d.render(sess, previewTab)
177+
178+ previewOut := awaitFrame(t, sends, ports.MsgOutput)
179+ previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
180+ require.NoError(t, err)
181+ require.Contains(t, string(previewMsg.Data), "inactive-preview-live")
182+}
183+
184+func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
185+ p1, releasePTY1 := newBlockingPTY(t)
186+ p2, releasePTY2 := newBlockingPTY(t)
187+ defer releasePTY1()
188+ defer releasePTY2()
189+ d := newTestDaemon(t, nil, stubClock{})
190+ tr1, sends1 := newCapturingTransport(t)
191+ tr2, _ := newCapturingTransport(t)
192+ ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
193+ ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
194+ sctx1, cancel1 := context.WithCancel(d.serveCtx)
195+ sctx2, cancel2 := context.WithCancel(d.serveCtx)
196+ defer cancel1()
197+ defer cancel2()
198+ sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1}
199+ sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2}
200+ ac1.setSession(sess1)
201+ ac2.setSession(sess2)
202+ ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
203+ ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2})
204+ d.sessions[sess1.id] = sess1
205+ d.sessions[sess2.id] = sess2
206+
207+ d.handleInput(sess1, ac1, []byte("\x1bt"))
208+ awaitFrame(t, sends1, ports.MsgOutput)
209+ d.handleInput(sess1, ac1, []byte("j"))
210+ awaitFrame(t, sends1, ports.MsgOutput)
211+
212+ previewTab := sess2.tabs[0]
213+ previewTab.mu.Lock()
214+ previewTab.screen.Write([]byte("cross-session-preview-live"))
215+ previewTab.mu.Unlock()
216+ d.render(sess2, previewTab)
217+
218+ previewOut := awaitFrame(t, sends1, ports.MsgOutput)
219+ previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
220+ require.NoError(t, err)
221+ require.Contains(t, string(previewMsg.Data), "cross-session-preview-live")
222+}
223+
224+func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) {
225+ d, sess, ac, sends, releases := newManualTabSession(t, 3)
226+ defer func() {
227+ for _, release := range releases {
228+ release()
229+ }
230+ }()
231+
232+ done := make(chan struct{})
233+ var drain sync.WaitGroup
234+ drain.Go(func() {
235+ for {
236+ select {
237+ case <-sends:
238+ case <-done:
239+ return
240+ }
241+ }
242+ })
243+
244+ var wg sync.WaitGroup
245+ for i := range 100 {
246+ wg.Go(func() {
247+ d.enterPicker(sess, ac)
248+ })
249+ wg.Go(func() {
250+ d.handlePickerInput(ac, []byte("j"))
251+ d.handlePickerInput(ac, []byte("k"))
252+ })
253+ wg.Go(func() {
254+ d.closePicker(ac)
255+ })
256+ tb := sess.tabs[i%len(sess.tabs)]
257+ wg.Go(func() {
258+ tb.mu.Lock()
259+ tb.screen.Write([]byte("render-race"))
260+ tb.mu.Unlock()
261+ d.render(sess, tb)
262+ })
263+ }
264+ wg.Wait()
265+ close(done)
266+ drain.Wait()
267+}
268+
269+func TestPickerResizeRecomposesModal(t *testing.T) {
270+ p, releasePTY := newBlockingPTY(t)
271+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
272+ defer releasePTY()
273+
274+ d.handleInput(sess, ac, []byte("\x1bt"))
275+ awaitFrame(t, sends, ports.MsgOutput)
276+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
277+
278+ out := awaitFrame(t, sends, ports.MsgOutput)
279+ msg, err := ports.UnmarshalOutput(out.Payload)
280+ require.NoError(t, err)
281+ require.Contains(t, string(msg.Data), "┌")
282+ require.Contains(t, string(msg.Data), "Sessions")
283+}
A internal/usecase/daemon/render_test.go +610 -0
@@ -0,0 +1,610 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "log/slog"
9+ "strings"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "github.com/bnema/vev/internal/domain"
18+ "github.com/bnema/vev/internal/ports"
19+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
20+ "github.com/bnema/vev/pkg/renderer"
21+ "github.com/bnema/vev/pkg/vt"
22+)
23+
24+// --- test doubles -----------------------------------------------------------
25+
26+// stubClock returns timers whose channel never fires, so a scheduler under it
27+// blocks in its debounce loop until the session context is cancelled. Used by
28+
29+func TestPTYWriteErrorIsLogged(t *testing.T) {
30+ var logs bytes.Buffer
31+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logs, nil)))
32+ errBoom := errors.New("boom")
33+ p := portsmocks.NewMockPTY(t)
34+ p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once()
35+ win := &tab{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}}
36+ sess := &session{id: "manual", name: "work", tabs: []*tab{win}}
37+ ac := &attachedClient{}
38+ ac.setSession(sess)
39+
40+ daemonKeyHandler{d: d, ac: ac}.Forward([]byte("input"))
41+
42+ got := logs.String()
43+ if !strings.Contains(got, "pty write failed") || !strings.Contains(got, "boom") || !strings.Contains(got, "work") {
44+ t.Fatalf("log output %q does not contain PTY write failure details", got)
45+ }
46+}
47+
48+func TestAltXClosesNonFinalTabScheduler(t *testing.T) {
49+ p1, releasePTY1 := newBlockingPTY(t)
50+ p2, releasePTY2 := newBlockingPTY(t)
51+ clk := &signalClock{called: make(chan struct{})}
52+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
53+ d.clock = clk
54+ defer releasePTY1()
55+ defer releasePTY2()
56+
57+ d.sessWg.Add(1)
58+ go d.scheduler(sess, sess.tabs[1])
59+ sess.tabs[1].dirty <- struct{}{}
60+ <-clk.called
61+
62+ d.handleInput(sess, ac, []byte("\x1b2"))
63+ d.handleInput(sess, ac, []byte("\x1bx"))
64+
65+ select {
66+ case <-waitGroupDone(&d.sessWg):
67+ case <-time.After(2 * time.Second):
68+ t.Fatal("scheduler for removed tab did not exit")
69+ }
70+ require.Equal(t, 1, sessionCount(d))
71+}
72+
73+func TestPTYEOFClosesNonFinalTabScheduler(t *testing.T) {
74+ p1, releasePTY1 := newBlockingPTY(t)
75+ p2, releasePTY2 := newBlockingPTY(t)
76+ clk := &signalClock{called: make(chan struct{})}
77+ d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
78+ d.clock = clk
79+ defer releasePTY1()
80+ defer releasePTY2()
81+
82+ d.sessWg.Add(2)
83+ go d.scheduler(sess, sess.tabs[1])
84+ go d.ptyReader(sess, sess.tabs[1])
85+ sess.tabs[1].dirty <- struct{}{}
86+ <-clk.called
87+ releasePTY2()
88+
89+ select {
90+ case <-waitGroupDone(&d.sessWg):
91+ case <-time.After(2 * time.Second):
92+ t.Fatal("scheduler for EOF-removed tab did not exit")
93+ }
94+ require.Equal(t, 1, sessionCount(d))
95+}
96+
97+func TestAltXClosesFinalTabAndDetaches(t *testing.T) {
98+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
99+ defer releases[0]()
100+
101+ d.handleInput(sess, ac, []byte("\x1bx"))
102+
103+ require.Equal(t, 0, sessionCount(d))
104+ f := awaitFrame(t, sends, ports.MsgDetached)
105+ det, err := ports.UnmarshalDetached(f.Payload)
106+ require.NoError(t, err)
107+ require.Equal(t, ports.ReasonSessionKilled, det.Reason)
108+}
109+
110+func TestPTYEOFClosesActiveNonFinalTabAndRepaintsRemaining(t *testing.T) {
111+ p1, releasePTY1 := newBlockingPTY(t)
112+ p2, releasePTY2 := newBlockingPTY(t)
113+ d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
114+ defer releasePTY2()
115+ sess.active = 0
116+ sess.tabs[1].screen.Write([]byte("remaining"))
117+
118+ d.sessWg.Add(1)
119+ go d.ptyReader(sess, sess.tabs[0])
120+ releasePTY1()
121+
122+ require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
123+ require.Equal(t, 1, sessionCount(d))
124+ require.Equal(t, 0, activeTabIndex(sess))
125+ f := awaitFrame(t, sends, ports.MsgOutput)
126+ out, err := ports.UnmarshalOutput(f.Payload)
127+ require.NoError(t, err)
128+ data := string(out.Data)
129+ require.Contains(t, data, "remaining")
130+ require.Contains(t, data, "work")
131+ require.Contains(t, data, ";7m")
132+
133+ d.sessWg.Wait()
134+}
135+
136+func TestPTYEOFClosesInactiveNonFinalTabAndRepaintsStatus(t *testing.T) {
137+ p1, releasePTY1 := newBlockingPTY(t)
138+ p2, releasePTY2 := newBlockingPTY(t)
139+ d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
140+ defer releasePTY1()
141+ sess.active = 0
142+ sess.tabs[0].screen.Write([]byte("active"))
143+
144+ d.sessWg.Add(1)
145+ go d.ptyReader(sess, sess.tabs[1])
146+ releasePTY2()
147+
148+ require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
149+ require.Equal(t, 1, sessionCount(d))
150+ require.Equal(t, 0, activeTabIndex(sess))
151+ f := awaitFrame(t, sends, ports.MsgOutput)
152+ out, err := ports.UnmarshalOutput(f.Payload)
153+ require.NoError(t, err)
154+ data := string(out.Data)
155+ require.Contains(t, data, "active")
156+ require.Contains(t, data, "work")
157+ require.NotContains(t, data, " 2 ")
158+
159+ d.sessWg.Wait()
160+}
161+
162+func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) {
163+ d, sess, _, sends, releases := newManualTabSession(t, 1)
164+
165+ d.sessWg.Add(1)
166+ go d.ptyReader(sess, sess.tabs[0])
167+ releases[0]()
168+
169+ require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond)
170+ f := awaitFrame(t, sends, ports.MsgDetached)
171+ det, err := ports.UnmarshalDetached(f.Payload)
172+ require.NoError(t, err)
173+ require.Equal(t, ports.ReasonSessionKilled, det.Reason)
174+
175+ d.sessWg.Wait()
176+}
177+
178+func TestSchedulerDebounceCoalesces(t *testing.T) {
179+ mc := portsmocks.NewMockClock(t)
180+ mc.EXPECT().Now().Return(time.Now()).Maybe()
181+ mt := portsmocks.NewMockTimer(t)
182+ timerCh := make(chan time.Time, 1)
183+ newTimerCalled := make(chan struct{}, 4)
184+
185+ mc.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
186+ select {
187+ case newTimerCalled <- struct{}{}:
188+ default:
189+ }
190+ return mt
191+ }).Maybe()
192+ mt.EXPECT().C().Return(timerCh).Maybe()
193+ mt.EXPECT().Stop().Return(true).Maybe()
194+
195+ var outputs atomic.Int32
196+ gotOutput := make(chan struct{}, 1)
197+ tr := portsmocks.NewMockTransport(t)
198+ tr.EXPECT().Close().Return(nil).Maybe()
199+ tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
200+ if f.Type == ports.MsgOutput {
201+ outputs.Add(1)
202+ select {
203+ case gotOutput <- struct{}{}:
204+ default:
205+ }
206+ }
207+ return nil
208+ }).Maybe()
209+
210+ d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
211+ sctx, cancel := context.WithCancel(context.Background())
212+ win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
213+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
214+ sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
215+ ac.setSession(sess)
216+
217+ win.mu.Lock()
218+ win.screen.Write([]byte("hi"))
219+ win.mu.Unlock()
220+
221+ d.sessWg.Add(1)
222+ go d.scheduler(sess, win)
223+
224+ // First dirty opens the debounce window.
225+ win.dirty <- struct{}{}
226+ <-newTimerCalled
227+
228+ // A burst of further dirties inside the window must be absorbed.
229+ for range 5 {
230+ select {
231+ case win.dirty <- struct{}{}:
232+ default:
233+ }
234+ }
235+
236+ // Fire the timer once: exactly one render.
237+ timerCh <- time.Now()
238+ <-gotOutput
239+
240+ cancel()
241+ d.sessWg.Wait()
242+ require.Equal(t, int32(1), outputs.Load(), "N dirties in one tab must render exactly once")
243+}
244+
245+func TestSchedulerAdaptiveDebounceFloodAndIdle(t *testing.T) {
246+ delay := nextDebounceDelay(minDebounceInterval, 3)
247+ require.Greater(t, delay, minDebounceInterval, "sustained flood should widen debounce")
248+ delay = nextDebounceDelay(delay, 3)
249+ require.Greater(t, delay, minDebounceInterval+debounceStep, "continued flood should keep adapting")
250+}
251+
252+func TestSchedulerAdaptiveDebounceResetsAfterQuietPeriod(t *testing.T) {
253+ delay := nextDebounceDelay(minDebounceInterval, 2)
254+ require.Greater(t, delay, minDebounceInterval)
255+ require.Equal(t, minDebounceInterval, nextDebounceDelay(delay, 0), "isolated update after quiet window should restore idle latency")
256+}
257+
258+// --- resize ordering --------------------------------------------------------
259+
260+func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
261+ newSize := domain.Size{Cols: 100, Rows: 30}
262+
263+ p := portsmocks.NewMockPTY(t)
264+ var screenWidthAtResize int
265+ win := &tab{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}}
266+ p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 29}).RunAndReturn(func(sz domain.Size) error {
267+ // The screen must not yet be resized when the PTY is: proves order.
268+ screenWidthAtResize = win.screen.Frame.Width
269+ return nil
270+ }).Once()
271+ win.pty = p
272+
273+ var gotOutput atomic.Bool
274+ tr := portsmocks.NewMockTransport(t)
275+ tr.EXPECT().Close().Return(nil).Maybe()
276+ tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
277+ if f.Type == ports.MsgOutput {
278+ gotOutput.Store(true)
279+ }
280+ return nil
281+ }).Maybe()
282+
283+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
284+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
285+ sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}
286+ ac.setSession(sess)
287+
288+ d.resize(sess, ac, newSize)
289+
290+ require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")
291+ require.Equal(t, 100, win.screen.Frame.Width, "screen resized after pty")
292+ require.Equal(t, 29, win.screen.Frame.Height, "screen reserves bottom status row")
293+ require.True(t, gotOutput.Load(), "resize forces a full redraw output")
294+}
295+
296+// --- reader EOF -> registry-empty shutdown ----------------------------------
297+
298+func TestSendErrorKillsEphemeral(t *testing.T) {
299+ p := portsmocks.NewMockPTY(t)
300+ p.EXPECT().Close().Return(nil).Maybe()
301+
302+ tr := portsmocks.NewMockTransport(t)
303+ tr.EXPECT().Send(mock.Anything).Return(io.ErrClosedPipe).Maybe()
304+ tr.EXPECT().Close().Return(nil).Maybe()
305+
306+ d := newTestDaemon(t, nil, stubClock{})
307+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
308+ sctx, cancel := context.WithCancel(context.Background())
309+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
310+ sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
311+ ac.setSession(sess)
312+ d.sessions[sess.id] = sess
313+
314+ win.mu.Lock()
315+ win.screen.Write([]byte("x"))
316+ win.mu.Unlock()
317+
318+ d.paint(sess, ac, true) // send fails -> detach -> ephemeral killed
319+
320+ require.Equal(t, 0, sessionCount(d), "ephemeral session must die when its client's send fails")
321+ d.waitNotifies()
322+}
323+
324+func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T) {
325+ mc := portsmocks.NewMockClock(t)
326+ mc.EXPECT().Now().Return(time.Now()).Maybe()
327+ mt := portsmocks.NewMockTimer(t)
328+ timerCh := make(chan time.Time, 1)
329+ mc.EXPECT().NewTimer(mock.Anything).Return(mt).Maybe()
330+ mt.EXPECT().C().Return(timerCh).Maybe()
331+ mt.EXPECT().Stop().Return(true).Maybe()
332+
333+ var outputs atomic.Int32
334+ gotOutput := make(chan struct{}, 1)
335+ tr := portsmocks.NewMockTransport(t)
336+ tr.EXPECT().Close().Return(nil).Maybe()
337+ tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
338+ if f.Type == ports.MsgOutput {
339+ outputs.Add(1)
340+ select {
341+ case gotOutput <- struct{}{}:
342+ default:
343+ }
344+ }
345+ return nil
346+ }).Maybe()
347+
348+ d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
349+ sctx, cancel := context.WithCancel(context.Background())
350+ win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
351+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
352+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
353+ ac.setSession(sess)
354+
355+ win.mu.Lock()
356+ win.screen.Write([]byte("before"))
357+ win.mu.Unlock()
358+
359+ d.sessWg.Add(1)
360+ go d.scheduler(sess, win)
361+ win.dirty <- struct{}{}
362+
363+ win.mu.Lock()
364+ win.screen.Write([]byte("\x1b[?2026hafter"))
365+ win.mu.Unlock()
366+ timerCh <- time.Now()
367+
368+ select {
369+ case <-gotOutput:
370+ t.Fatal("scheduler rendered while synchronized update was active")
371+ case <-time.After(20 * time.Millisecond):
372+ }
373+ require.Equal(t, int32(0), outputs.Load())
374+
375+ win.mu.Lock()
376+ win.screen.Write([]byte(" done\x1b[?2026l"))
377+ win.mu.Unlock()
378+ win.flush <- struct{}{}
379+ select {
380+ case <-gotOutput:
381+ case <-time.After(time.Second):
382+ t.Fatal("sync end did not flush deferred damage")
383+ }
384+
385+ cancel()
386+ d.sessWg.Wait()
387+}
388+
389+func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
390+ d := newTestDaemon(t, nil, stubClock{})
391+ p := portsmocks.NewMockPTY(t)
392+ chunks := [][]byte{[]byte("\x1b[6n")}
393+ writes := make(chan []byte, 1)
394+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
395+ if len(chunks) == 0 {
396+ return 0, io.EOF
397+ }
398+ n := copy(buf, chunks[0])
399+ chunks = chunks[1:]
400+ return n, nil
401+ })
402+ p.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
403+ writes <- append([]byte(nil), b...)
404+ return len(b), nil
405+ }).Once()
406+ p.EXPECT().Close().Return(nil).Maybe()
407+
408+ tr, sends := newCapturingTransport(t)
409+ sctx, cancel := context.WithCancel(context.Background())
410+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
411+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
412+ sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
413+ ac.setSession(sess)
414+ d.sessions[sess.id] = sess
415+ d.sessWg.Add(1)
416+ d.ptyReader(sess, win)
417+
418+ select {
419+ case got := <-writes:
420+ require.Equal(t, []byte("\x1b[1;1R"), got)
421+ case <-time.After(time.Second):
422+ t.Fatal("timed out waiting for PTY response write")
423+ }
424+ select {
425+ case f := <-sends:
426+ require.NotEqual(t, ports.MsgOutput, f.Type)
427+ default:
428+ }
429+}
430+
431+func TestPTYReaderSameReadSynchronizedUpdateStartEndFlushesImmediately(t *testing.T) {
432+ d := newTestDaemon(t, nil, stubClock{})
433+ p := portsmocks.NewMockPTY(t)
434+ chunks := [][]byte{[]byte("\x1b[?2026hhello\x1b[?2026l")}
435+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
436+ if len(chunks) == 0 {
437+ return 0, io.EOF
438+ }
439+ n := copy(buf, chunks[0])
440+ chunks = chunks[1:]
441+ return n, nil
442+ })
443+ p.EXPECT().Close().Return(nil).Maybe()
444+
445+ sctx, cancel := context.WithCancel(context.Background())
446+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
447+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
448+ d.sessions[sess.id] = sess
449+ d.sessWg.Add(1)
450+ d.ptyReader(sess, win)
451+
452+ select {
453+ case <-win.dirty:
454+ t.Fatal("same-read synchronized update end should request flush, not dirty")
455+ default:
456+ }
457+ select {
458+ case <-win.flush:
459+ case <-time.After(time.Second):
460+ t.Fatal("same-read synchronized update end did not request immediate flush")
461+ }
462+}
463+
464+func TestPTYReaderDefersDirtyDuringSynchronizedUpdateAndFlushesAtEnd(t *testing.T) {
465+ d := newTestDaemon(t, nil, stubClock{})
466+ p := portsmocks.NewMockPTY(t)
467+ chunks := [][]byte{[]byte("\x1b[?2026hhello"), []byte(" world\x1b[?2026l")}
468+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
469+ if len(chunks) == 0 {
470+ return 0, io.EOF
471+ }
472+ n := copy(buf, chunks[0])
473+ chunks = chunks[1:]
474+ return n, nil
475+ })
476+ p.EXPECT().Close().Return(nil).Maybe()
477+
478+ sctx, cancel := context.WithCancel(context.Background())
479+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
480+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
481+ d.sessions[sess.id] = sess
482+ d.sessWg.Add(1)
483+ d.ptyReader(sess, win)
484+
485+ select {
486+ case <-win.dirty:
487+ t.Fatal("dirty signaled while synchronized update was active")
488+ default:
489+ }
490+ select {
491+ case <-win.flush:
492+ case <-time.After(time.Second):
493+ t.Fatal("sync end did not request immediate flush")
494+ }
495+}
496+
497+func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) {
498+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
499+ d := newTestDaemon(t, nil, clk)
500+ p := portsmocks.NewMockPTY(t)
501+ release := make(chan struct{})
502+ read := 0
503+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
504+ if read == 0 {
505+ read++
506+ return copy(buf, []byte("\x1b[?2026habandoned")), nil
507+ }
508+ <-release
509+ return 0, io.EOF
510+ })
511+ p.EXPECT().Close().Return(nil).Maybe()
512+
513+ tr, sends := newCapturingTransport(t)
514+ sctx, cancel := context.WithCancel(context.Background())
515+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
516+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
517+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
518+ ac.setSession(sess)
519+ d.sessions[sess.id] = sess
520+ d.sessWg.Add(2)
521+ go d.scheduler(sess, win)
522+ go d.ptyReader(sess, win)
523+
524+ timer := <-clk.timers
525+ timer.ch <- time.Now()
526+ awaitFrame(t, sends, ports.MsgOutput)
527+
528+ win.mu.Lock()
529+ active := win.screen.SyncUpdateActive()
530+ got := screenLineText(win.screen, 0)
531+ win.mu.Unlock()
532+ require.False(t, active)
533+ require.Contains(t, got, "abandoned")
534+
535+ close(release)
536+ cancel()
537+ d.sessWg.Wait()
538+}
539+
540+func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
541+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
542+ d := newTestDaemon(t, nil, clk)
543+ p := portsmocks.NewMockPTY(t)
544+ release := make(chan struct{})
545+ chunks := [][]byte{[]byte("\x1b[?2026hhello"), []byte(" world\x1b[?2026l")}
546+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
547+ if len(chunks) > 0 {
548+ n := copy(buf, chunks[0])
549+ chunks = chunks[1:]
550+ return n, nil
551+ }
552+ <-release
553+ return 0, io.EOF
554+ })
555+ p.EXPECT().Close().Return(nil).Maybe()
556+
557+ tr, sends := newCapturingTransport(t)
558+ sctx, cancel := context.WithCancel(context.Background())
559+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
560+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
561+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
562+ ac.setSession(sess)
563+ d.sessions[sess.id] = sess
564+ d.sessWg.Add(2)
565+ go d.scheduler(sess, win)
566+ go d.ptyReader(sess, win)
567+
568+ timer := <-clk.timers
569+ awaitFrame(t, sends, ports.MsgOutput)
570+ timer.ch <- time.Now()
571+
572+ select {
573+ case f := <-sends:
574+ if f.Type == ports.MsgOutput {
575+ t.Fatal("stale synchronized update watchdog produced an extra render")
576+ }
577+ case <-time.After(50 * time.Millisecond):
578+ }
579+ win.mu.Lock()
580+ active := win.screen.SyncUpdateActive()
581+ win.mu.Unlock()
582+ require.False(t, active)
583+
584+ close(release)
585+ cancel()
586+ d.sessWg.Wait()
587+}
588+
589+func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
590+ p, _ := newBlockingPTY(t)
591+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
592+ win := sess.tabs[0]
593+ win.screen.Write([]byte("A"))
594+
595+ d.paint(sess, ac, true)
596+ data := mustOutputData(t, sends)
597+ require.Contains(t, string(data), "\x1b[5 q")
598+ require.Contains(t, string(data), "\x1b[?25h")
599+
600+ win.screen.Write([]byte("\x1b[2;3H"))
601+ d.paint(sess, ac, false)
602+ data = mustOutputData(t, sends)
603+ require.Contains(t, string(data), "\x1b[2;3H")
604+ require.Contains(t, string(data), "\x1b[?25h")
605+
606+ win.screen.Write([]byte("\x1b[?25l"))
607+ d.paint(sess, ac, false)
608+ data = mustOutputData(t, sends)
609+ require.Contains(t, string(data), "\x1b[?25l")
610+}
@@ -0,0 +1,610 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "log/slog"
9+ "strings"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "github.com/bnema/vev/internal/domain"
18+ "github.com/bnema/vev/internal/ports"
19+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
20+ "github.com/bnema/vev/pkg/renderer"
21+ "github.com/bnema/vev/pkg/vt"
22+)
23+
24+// --- test doubles -----------------------------------------------------------
25+
26+// stubClock returns timers whose channel never fires, so a scheduler under it
27+// blocks in its debounce loop until the session context is cancelled. Used by
28+
29+func TestPTYWriteErrorIsLogged(t *testing.T) {
30+ var logs bytes.Buffer
31+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logs, nil)))
32+ errBoom := errors.New("boom")
33+ p := portsmocks.NewMockPTY(t)
34+ p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once()
35+ win := &tab{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}}
36+ sess := &session{id: "manual", name: "work", tabs: []*tab{win}}
37+ ac := &attachedClient{}
38+ ac.setSession(sess)
39+
40+ daemonKeyHandler{d: d, ac: ac}.Forward([]byte("input"))
41+
42+ got := logs.String()
43+ if !strings.Contains(got, "pty write failed") || !strings.Contains(got, "boom") || !strings.Contains(got, "work") {
44+ t.Fatalf("log output %q does not contain PTY write failure details", got)
45+ }
46+}
47+
48+func TestAltXClosesNonFinalTabScheduler(t *testing.T) {
49+ p1, releasePTY1 := newBlockingPTY(t)
50+ p2, releasePTY2 := newBlockingPTY(t)
51+ clk := &signalClock{called: make(chan struct{})}
52+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
53+ d.clock = clk
54+ defer releasePTY1()
55+ defer releasePTY2()
56+
57+ d.sessWg.Add(1)
58+ go d.scheduler(sess, sess.tabs[1])
59+ sess.tabs[1].dirty <- struct{}{}
60+ <-clk.called
61+
62+ d.handleInput(sess, ac, []byte("\x1b2"))
63+ d.handleInput(sess, ac, []byte("\x1bx"))
64+
65+ select {
66+ case <-waitGroupDone(&d.sessWg):
67+ case <-time.After(2 * time.Second):
68+ t.Fatal("scheduler for removed tab did not exit")
69+ }
70+ require.Equal(t, 1, sessionCount(d))
71+}
72+
73+func TestPTYEOFClosesNonFinalTabScheduler(t *testing.T) {
74+ p1, releasePTY1 := newBlockingPTY(t)
75+ p2, releasePTY2 := newBlockingPTY(t)
76+ clk := &signalClock{called: make(chan struct{})}
77+ d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
78+ d.clock = clk
79+ defer releasePTY1()
80+ defer releasePTY2()
81+
82+ d.sessWg.Add(2)
83+ go d.scheduler(sess, sess.tabs[1])
84+ go d.ptyReader(sess, sess.tabs[1])
85+ sess.tabs[1].dirty <- struct{}{}
86+ <-clk.called
87+ releasePTY2()
88+
89+ select {
90+ case <-waitGroupDone(&d.sessWg):
91+ case <-time.After(2 * time.Second):
92+ t.Fatal("scheduler for EOF-removed tab did not exit")
93+ }
94+ require.Equal(t, 1, sessionCount(d))
95+}
96+
97+func TestAltXClosesFinalTabAndDetaches(t *testing.T) {
98+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
99+ defer releases[0]()
100+
101+ d.handleInput(sess, ac, []byte("\x1bx"))
102+
103+ require.Equal(t, 0, sessionCount(d))
104+ f := awaitFrame(t, sends, ports.MsgDetached)
105+ det, err := ports.UnmarshalDetached(f.Payload)
106+ require.NoError(t, err)
107+ require.Equal(t, ports.ReasonSessionKilled, det.Reason)
108+}
109+
110+func TestPTYEOFClosesActiveNonFinalTabAndRepaintsRemaining(t *testing.T) {
111+ p1, releasePTY1 := newBlockingPTY(t)
112+ p2, releasePTY2 := newBlockingPTY(t)
113+ d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
114+ defer releasePTY2()
115+ sess.active = 0
116+ sess.tabs[1].screen.Write([]byte("remaining"))
117+
118+ d.sessWg.Add(1)
119+ go d.ptyReader(sess, sess.tabs[0])
120+ releasePTY1()
121+
122+ require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
123+ require.Equal(t, 1, sessionCount(d))
124+ require.Equal(t, 0, activeTabIndex(sess))
125+ f := awaitFrame(t, sends, ports.MsgOutput)
126+ out, err := ports.UnmarshalOutput(f.Payload)
127+ require.NoError(t, err)
128+ data := string(out.Data)
129+ require.Contains(t, data, "remaining")
130+ require.Contains(t, data, "work")
131+ require.Contains(t, data, ";7m")
132+
133+ d.sessWg.Wait()
134+}
135+
136+func TestPTYEOFClosesInactiveNonFinalTabAndRepaintsStatus(t *testing.T) {
137+ p1, releasePTY1 := newBlockingPTY(t)
138+ p2, releasePTY2 := newBlockingPTY(t)
139+ d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
140+ defer releasePTY1()
141+ sess.active = 0
142+ sess.tabs[0].screen.Write([]byte("active"))
143+
144+ d.sessWg.Add(1)
145+ go d.ptyReader(sess, sess.tabs[1])
146+ releasePTY2()
147+
148+ require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
149+ require.Equal(t, 1, sessionCount(d))
150+ require.Equal(t, 0, activeTabIndex(sess))
151+ f := awaitFrame(t, sends, ports.MsgOutput)
152+ out, err := ports.UnmarshalOutput(f.Payload)
153+ require.NoError(t, err)
154+ data := string(out.Data)
155+ require.Contains(t, data, "active")
156+ require.Contains(t, data, "work")
157+ require.NotContains(t, data, " 2 ")
158+
159+ d.sessWg.Wait()
160+}
161+
162+func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) {
163+ d, sess, _, sends, releases := newManualTabSession(t, 1)
164+
165+ d.sessWg.Add(1)
166+ go d.ptyReader(sess, sess.tabs[0])
167+ releases[0]()
168+
169+ require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond)
170+ f := awaitFrame(t, sends, ports.MsgDetached)
171+ det, err := ports.UnmarshalDetached(f.Payload)
172+ require.NoError(t, err)
173+ require.Equal(t, ports.ReasonSessionKilled, det.Reason)
174+
175+ d.sessWg.Wait()
176+}
177+
178+func TestSchedulerDebounceCoalesces(t *testing.T) {
179+ mc := portsmocks.NewMockClock(t)
180+ mc.EXPECT().Now().Return(time.Now()).Maybe()
181+ mt := portsmocks.NewMockTimer(t)
182+ timerCh := make(chan time.Time, 1)
183+ newTimerCalled := make(chan struct{}, 4)
184+
185+ mc.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
186+ select {
187+ case newTimerCalled <- struct{}{}:
188+ default:
189+ }
190+ return mt
191+ }).Maybe()
192+ mt.EXPECT().C().Return(timerCh).Maybe()
193+ mt.EXPECT().Stop().Return(true).Maybe()
194+
195+ var outputs atomic.Int32
196+ gotOutput := make(chan struct{}, 1)
197+ tr := portsmocks.NewMockTransport(t)
198+ tr.EXPECT().Close().Return(nil).Maybe()
199+ tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
200+ if f.Type == ports.MsgOutput {
201+ outputs.Add(1)
202+ select {
203+ case gotOutput <- struct{}{}:
204+ default:
205+ }
206+ }
207+ return nil
208+ }).Maybe()
209+
210+ d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
211+ sctx, cancel := context.WithCancel(context.Background())
212+ win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
213+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
214+ sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
215+ ac.setSession(sess)
216+
217+ win.mu.Lock()
218+ win.screen.Write([]byte("hi"))
219+ win.mu.Unlock()
220+
221+ d.sessWg.Add(1)
222+ go d.scheduler(sess, win)
223+
224+ // First dirty opens the debounce window.
225+ win.dirty <- struct{}{}
226+ <-newTimerCalled
227+
228+ // A burst of further dirties inside the window must be absorbed.
229+ for range 5 {
230+ select {
231+ case win.dirty <- struct{}{}:
232+ default:
233+ }
234+ }
235+
236+ // Fire the timer once: exactly one render.
237+ timerCh <- time.Now()
238+ <-gotOutput
239+
240+ cancel()
241+ d.sessWg.Wait()
242+ require.Equal(t, int32(1), outputs.Load(), "N dirties in one tab must render exactly once")
243+}
244+
245+func TestSchedulerAdaptiveDebounceFloodAndIdle(t *testing.T) {
246+ delay := nextDebounceDelay(minDebounceInterval, 3)
247+ require.Greater(t, delay, minDebounceInterval, "sustained flood should widen debounce")
248+ delay = nextDebounceDelay(delay, 3)
249+ require.Greater(t, delay, minDebounceInterval+debounceStep, "continued flood should keep adapting")
250+}
251+
252+func TestSchedulerAdaptiveDebounceResetsAfterQuietPeriod(t *testing.T) {
253+ delay := nextDebounceDelay(minDebounceInterval, 2)
254+ require.Greater(t, delay, minDebounceInterval)
255+ require.Equal(t, minDebounceInterval, nextDebounceDelay(delay, 0), "isolated update after quiet window should restore idle latency")
256+}
257+
258+// --- resize ordering --------------------------------------------------------
259+
260+func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
261+ newSize := domain.Size{Cols: 100, Rows: 30}
262+
263+ p := portsmocks.NewMockPTY(t)
264+ var screenWidthAtResize int
265+ win := &tab{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}}
266+ p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 29}).RunAndReturn(func(sz domain.Size) error {
267+ // The screen must not yet be resized when the PTY is: proves order.
268+ screenWidthAtResize = win.screen.Frame.Width
269+ return nil
270+ }).Once()
271+ win.pty = p
272+
273+ var gotOutput atomic.Bool
274+ tr := portsmocks.NewMockTransport(t)
275+ tr.EXPECT().Close().Return(nil).Maybe()
276+ tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
277+ if f.Type == ports.MsgOutput {
278+ gotOutput.Store(true)
279+ }
280+ return nil
281+ }).Maybe()
282+
283+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
284+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
285+ sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}
286+ ac.setSession(sess)
287+
288+ d.resize(sess, ac, newSize)
289+
290+ require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")
291+ require.Equal(t, 100, win.screen.Frame.Width, "screen resized after pty")
292+ require.Equal(t, 29, win.screen.Frame.Height, "screen reserves bottom status row")
293+ require.True(t, gotOutput.Load(), "resize forces a full redraw output")
294+}
295+
296+// --- reader EOF -> registry-empty shutdown ----------------------------------
297+
298+func TestSendErrorKillsEphemeral(t *testing.T) {
299+ p := portsmocks.NewMockPTY(t)
300+ p.EXPECT().Close().Return(nil).Maybe()
301+
302+ tr := portsmocks.NewMockTransport(t)
303+ tr.EXPECT().Send(mock.Anything).Return(io.ErrClosedPipe).Maybe()
304+ tr.EXPECT().Close().Return(nil).Maybe()
305+
306+ d := newTestDaemon(t, nil, stubClock{})
307+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
308+ sctx, cancel := context.WithCancel(context.Background())
309+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
310+ sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
311+ ac.setSession(sess)
312+ d.sessions[sess.id] = sess
313+
314+ win.mu.Lock()
315+ win.screen.Write([]byte("x"))
316+ win.mu.Unlock()
317+
318+ d.paint(sess, ac, true) // send fails -> detach -> ephemeral killed
319+
320+ require.Equal(t, 0, sessionCount(d), "ephemeral session must die when its client's send fails")
321+ d.waitNotifies()
322+}
323+
324+func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T) {
325+ mc := portsmocks.NewMockClock(t)
326+ mc.EXPECT().Now().Return(time.Now()).Maybe()
327+ mt := portsmocks.NewMockTimer(t)
328+ timerCh := make(chan time.Time, 1)
329+ mc.EXPECT().NewTimer(mock.Anything).Return(mt).Maybe()
330+ mt.EXPECT().C().Return(timerCh).Maybe()
331+ mt.EXPECT().Stop().Return(true).Maybe()
332+
333+ var outputs atomic.Int32
334+ gotOutput := make(chan struct{}, 1)
335+ tr := portsmocks.NewMockTransport(t)
336+ tr.EXPECT().Close().Return(nil).Maybe()
337+ tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
338+ if f.Type == ports.MsgOutput {
339+ outputs.Add(1)
340+ select {
341+ case gotOutput <- struct{}{}:
342+ default:
343+ }
344+ }
345+ return nil
346+ }).Maybe()
347+
348+ d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
349+ sctx, cancel := context.WithCancel(context.Background())
350+ win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
351+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
352+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
353+ ac.setSession(sess)
354+
355+ win.mu.Lock()
356+ win.screen.Write([]byte("before"))
357+ win.mu.Unlock()
358+
359+ d.sessWg.Add(1)
360+ go d.scheduler(sess, win)
361+ win.dirty <- struct{}{}
362+
363+ win.mu.Lock()
364+ win.screen.Write([]byte("\x1b[?2026hafter"))
365+ win.mu.Unlock()
366+ timerCh <- time.Now()
367+
368+ select {
369+ case <-gotOutput:
370+ t.Fatal("scheduler rendered while synchronized update was active")
371+ case <-time.After(20 * time.Millisecond):
372+ }
373+ require.Equal(t, int32(0), outputs.Load())
374+
375+ win.mu.Lock()
376+ win.screen.Write([]byte(" done\x1b[?2026l"))
377+ win.mu.Unlock()
378+ win.flush <- struct{}{}
379+ select {
380+ case <-gotOutput:
381+ case <-time.After(time.Second):
382+ t.Fatal("sync end did not flush deferred damage")
383+ }
384+
385+ cancel()
386+ d.sessWg.Wait()
387+}
388+
389+func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
390+ d := newTestDaemon(t, nil, stubClock{})
391+ p := portsmocks.NewMockPTY(t)
392+ chunks := [][]byte{[]byte("\x1b[6n")}
393+ writes := make(chan []byte, 1)
394+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
395+ if len(chunks) == 0 {
396+ return 0, io.EOF
397+ }
398+ n := copy(buf, chunks[0])
399+ chunks = chunks[1:]
400+ return n, nil
401+ })
402+ p.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
403+ writes <- append([]byte(nil), b...)
404+ return len(b), nil
405+ }).Once()
406+ p.EXPECT().Close().Return(nil).Maybe()
407+
408+ tr, sends := newCapturingTransport(t)
409+ sctx, cancel := context.WithCancel(context.Background())
410+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
411+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
412+ sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
413+ ac.setSession(sess)
414+ d.sessions[sess.id] = sess
415+ d.sessWg.Add(1)
416+ d.ptyReader(sess, win)
417+
418+ select {
419+ case got := <-writes:
420+ require.Equal(t, []byte("\x1b[1;1R"), got)
421+ case <-time.After(time.Second):
422+ t.Fatal("timed out waiting for PTY response write")
423+ }
424+ select {
425+ case f := <-sends:
426+ require.NotEqual(t, ports.MsgOutput, f.Type)
427+ default:
428+ }
429+}
430+
431+func TestPTYReaderSameReadSynchronizedUpdateStartEndFlushesImmediately(t *testing.T) {
432+ d := newTestDaemon(t, nil, stubClock{})
433+ p := portsmocks.NewMockPTY(t)
434+ chunks := [][]byte{[]byte("\x1b[?2026hhello\x1b[?2026l")}
435+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
436+ if len(chunks) == 0 {
437+ return 0, io.EOF
438+ }
439+ n := copy(buf, chunks[0])
440+ chunks = chunks[1:]
441+ return n, nil
442+ })
443+ p.EXPECT().Close().Return(nil).Maybe()
444+
445+ sctx, cancel := context.WithCancel(context.Background())
446+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
447+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
448+ d.sessions[sess.id] = sess
449+ d.sessWg.Add(1)
450+ d.ptyReader(sess, win)
451+
452+ select {
453+ case <-win.dirty:
454+ t.Fatal("same-read synchronized update end should request flush, not dirty")
455+ default:
456+ }
457+ select {
458+ case <-win.flush:
459+ case <-time.After(time.Second):
460+ t.Fatal("same-read synchronized update end did not request immediate flush")
461+ }
462+}
463+
464+func TestPTYReaderDefersDirtyDuringSynchronizedUpdateAndFlushesAtEnd(t *testing.T) {
465+ d := newTestDaemon(t, nil, stubClock{})
466+ p := portsmocks.NewMockPTY(t)
467+ chunks := [][]byte{[]byte("\x1b[?2026hhello"), []byte(" world\x1b[?2026l")}
468+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
469+ if len(chunks) == 0 {
470+ return 0, io.EOF
471+ }
472+ n := copy(buf, chunks[0])
473+ chunks = chunks[1:]
474+ return n, nil
475+ })
476+ p.EXPECT().Close().Return(nil).Maybe()
477+
478+ sctx, cancel := context.WithCancel(context.Background())
479+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
480+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
481+ d.sessions[sess.id] = sess
482+ d.sessWg.Add(1)
483+ d.ptyReader(sess, win)
484+
485+ select {
486+ case <-win.dirty:
487+ t.Fatal("dirty signaled while synchronized update was active")
488+ default:
489+ }
490+ select {
491+ case <-win.flush:
492+ case <-time.After(time.Second):
493+ t.Fatal("sync end did not request immediate flush")
494+ }
495+}
496+
497+func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) {
498+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
499+ d := newTestDaemon(t, nil, clk)
500+ p := portsmocks.NewMockPTY(t)
501+ release := make(chan struct{})
502+ read := 0
503+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
504+ if read == 0 {
505+ read++
506+ return copy(buf, []byte("\x1b[?2026habandoned")), nil
507+ }
508+ <-release
509+ return 0, io.EOF
510+ })
511+ p.EXPECT().Close().Return(nil).Maybe()
512+
513+ tr, sends := newCapturingTransport(t)
514+ sctx, cancel := context.WithCancel(context.Background())
515+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
516+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
517+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
518+ ac.setSession(sess)
519+ d.sessions[sess.id] = sess
520+ d.sessWg.Add(2)
521+ go d.scheduler(sess, win)
522+ go d.ptyReader(sess, win)
523+
524+ timer := <-clk.timers
525+ timer.ch <- time.Now()
526+ awaitFrame(t, sends, ports.MsgOutput)
527+
528+ win.mu.Lock()
529+ active := win.screen.SyncUpdateActive()
530+ got := screenLineText(win.screen, 0)
531+ win.mu.Unlock()
532+ require.False(t, active)
533+ require.Contains(t, got, "abandoned")
534+
535+ close(release)
536+ cancel()
537+ d.sessWg.Wait()
538+}
539+
540+func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
541+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
542+ d := newTestDaemon(t, nil, clk)
543+ p := portsmocks.NewMockPTY(t)
544+ release := make(chan struct{})
545+ chunks := [][]byte{[]byte("\x1b[?2026hhello"), []byte(" world\x1b[?2026l")}
546+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
547+ if len(chunks) > 0 {
548+ n := copy(buf, chunks[0])
549+ chunks = chunks[1:]
550+ return n, nil
551+ }
552+ <-release
553+ return 0, io.EOF
554+ })
555+ p.EXPECT().Close().Return(nil).Maybe()
556+
557+ tr, sends := newCapturingTransport(t)
558+ sctx, cancel := context.WithCancel(context.Background())
559+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
560+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
561+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
562+ ac.setSession(sess)
563+ d.sessions[sess.id] = sess
564+ d.sessWg.Add(2)
565+ go d.scheduler(sess, win)
566+ go d.ptyReader(sess, win)
567+
568+ timer := <-clk.timers
569+ awaitFrame(t, sends, ports.MsgOutput)
570+ timer.ch <- time.Now()
571+
572+ select {
573+ case f := <-sends:
574+ if f.Type == ports.MsgOutput {
575+ t.Fatal("stale synchronized update watchdog produced an extra render")
576+ }
577+ case <-time.After(50 * time.Millisecond):
578+ }
579+ win.mu.Lock()
580+ active := win.screen.SyncUpdateActive()
581+ win.mu.Unlock()
582+ require.False(t, active)
583+
584+ close(release)
585+ cancel()
586+ d.sessWg.Wait()
587+}
588+
589+func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
590+ p, _ := newBlockingPTY(t)
591+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
592+ win := sess.tabs[0]
593+ win.screen.Write([]byte("A"))
594+
595+ d.paint(sess, ac, true)
596+ data := mustOutputData(t, sends)
597+ require.Contains(t, string(data), "\x1b[5 q")
598+ require.Contains(t, string(data), "\x1b[?25h")
599+
600+ win.screen.Write([]byte("\x1b[2;3H"))
601+ d.paint(sess, ac, false)
602+ data = mustOutputData(t, sends)
603+ require.Contains(t, string(data), "\x1b[2;3H")
604+ require.Contains(t, string(data), "\x1b[?25h")
605+
606+ win.screen.Write([]byte("\x1b[?25l"))
607+ d.paint(sess, ac, false)
608+ data = mustOutputData(t, sends)
609+ require.Contains(t, string(data), "\x1b[?25l")
610+}
A internal/usecase/daemon/session_test.go +532 -0
@@ -0,0 +1,532 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
17+)
18+
19+// --- test doubles -----------------------------------------------------------
20+
21+// stubClock returns timers whose channel never fires, so a scheduler under it
22+// blocks in its debounce loop until the session context is cancelled. Used by
23+
24+func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
25+ sz := domain.Size{Cols: 80, Rows: 24}
26+ first, releaseFirst := newBlockingPTY(t)
27+ defer releaseFirst()
28+ second, releaseSecond := newBlockingPTY(t)
29+ defer releaseSecond()
30+ f := portsmocks.NewMockPTYFactory(t)
31+ var dirs []string
32+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
33+ func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
34+ dirs = append(dirs, dir)
35+ if len(dirs) == 1 {
36+ return first, nil
37+ }
38+ return second, nil
39+ },
40+ ).Twice()
41+
42+ d := newTestDaemon(t, f, stubClock{})
43+ tr := portsmocks.NewMockTransport(t)
44+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
45+ tr.EXPECT().Close().Return(nil).Maybe()
46+
47+ sess, ac, err := d.route(ports.Hello{
48+ Version: ports.ProtocolVersion,
49+ Intent: ports.IntentNew,
50+ Name: "work",
51+ Size: sz,
52+ TermEnv: "xterm-256color",
53+ Cwd: "/tmp/work",
54+ }, tr)
55+ require.NoError(t, err)
56+ require.NotNil(t, ac)
57+ require.Equal(t, "/tmp/work", sess.cwd)
58+
59+ require.NoError(t, d.createTab(sess, sz))
60+ require.Equal(t, []string{"/tmp/work", "/tmp/work"}, dirs)
61+}
62+
63+func TestHandshakeEphemeralHappy(t *testing.T) {
64+ p, releasePTY := newBlockingPTY(t)
65+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
66+ tr, sends, releaseConn := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
67+
68+ var hg sync.WaitGroup
69+ hg.Go(func() { d.handleConn(tr) })
70+
71+ w := awaitFrame(t, sends, ports.MsgWelcome)
72+ welcome, err := ports.UnmarshalWelcome(w.Payload)
73+ require.NoError(t, err)
74+ require.Equal(t, "0", welcome.SessionName)
75+ require.True(t, welcome.Ephemeral)
76+ awaitFrame(t, sends, ports.MsgOutput) // guaranteed first paint
77+ require.Equal(t, 1, sessionCount(d))
78+
79+ releaseConn()
80+ releasePTY()
81+ hg.Wait()
82+ d.sessWg.Wait()
83+}
84+
85+func TestHandshakeNewHappy(t *testing.T) {
86+ p, releasePTY := newBlockingPTY(t)
87+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
88+ tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
89+
90+ var hg sync.WaitGroup
91+ hg.Go(func() { d.handleConn(tr) })
92+
93+ w := awaitFrame(t, sends, ports.MsgWelcome)
94+ welcome, err := ports.UnmarshalWelcome(w.Payload)
95+ require.NoError(t, err)
96+ require.Equal(t, "work", welcome.SessionName)
97+ require.False(t, welcome.Ephemeral)
98+
99+ sess := firstSession(d)
100+ require.NotNil(t, sess)
101+
102+ releaseConn()
103+ d.killSession(sess, ports.ReasonServerShutdown)
104+ releasePTY()
105+ hg.Wait()
106+ d.sessWg.Wait()
107+ d.waitNotifies()
108+}
109+
110+func TestHandshakeVersionMismatch(t *testing.T) {
111+ // No Open expectation: the factory must never be asked to spawn a PTY.
112+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
113+ bad := ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{
114+ Version: ports.ProtocolVersion + 99, Intent: ports.IntentEphemeral, Size: domain.Size{Cols: 80, Rows: 24},
115+ })}
116+ tr, sends, _ := newConn(t, bad)
117+
118+ d.handleConn(tr) // returns after the rejection; no session, no goroutines
119+
120+ e := awaitFrame(t, sends, ports.MsgError)
121+ em, err := ports.UnmarshalErrorMsg(e.Payload)
122+ require.NoError(t, err)
123+ require.Equal(t, ports.ErrVersionMismatch, em.Code)
124+ require.Equal(t, 0, sessionCount(d))
125+}
126+
127+func TestHandshakeOldHelloLayoutReportsVersionMismatch(t *testing.T) {
128+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
129+ oldLayout := []byte{
130+ 0x00, byte(ports.ProtocolVersion - 1),
131+ ports.IntentEphemeral,
132+ 0x00, 0x00,
133+ 0x00, 0x50,
134+ 0x00, 0x18,
135+ 0x00, 0x00,
136+ }
137+ tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: oldLayout})
138+ d.handleConn(tr)
139+
140+ e := awaitFrame(t, sends, ports.MsgError)
141+ em, err := ports.UnmarshalErrorMsg(e.Payload)
142+ require.NoError(t, err)
143+ require.Equal(t, ports.ErrVersionMismatch, em.Code)
144+ require.Equal(t, 0, sessionCount(d))
145+}
146+
147+func TestHandshakeNameTaken(t *testing.T) {
148+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
149+ // Pre-populate a bare session (no goroutines) to collide with.
150+ d.sessions[domain.SessionID("x")] = &session{id: "x", name: "work"}
151+
152+ tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
153+ d.handleConn(tr)
154+
155+ e := awaitFrame(t, sends, ports.MsgError)
156+ em, _ := ports.UnmarshalErrorMsg(e.Payload)
157+ require.Equal(t, ports.ErrNameTaken, em.Code)
158+}
159+
160+func TestHandshakeNoSuchSession(t *testing.T) {
161+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
162+ tr, sends, _ := newConn(t, mustHello(ports.IntentAttach, "ghost", domain.Size{Cols: 80, Rows: 24}))
163+ d.handleConn(tr)
164+
165+ e := awaitFrame(t, sends, ports.MsgError)
166+ em, _ := ports.UnmarshalErrorMsg(e.Payload)
167+ require.Equal(t, ports.ErrNoSuchSession, em.Code)
168+}
169+
170+func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) {
171+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
172+ tr, _, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})})
173+ d.handleConn(tr)
174+
175+ select {
176+ case <-d.done:
177+ case <-time.After(time.Second):
178+ t.Fatal("kill all on empty daemon did not signal shutdown")
179+ }
180+}
181+
182+// --- ephemeral numbering ----------------------------------------------------
183+
184+func TestEphemeralNumberingReuse(t *testing.T) {
185+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
186+ // Emulate two ephemeral sessions "0" and "1".
187+ d.sessions["a"] = &session{id: "a", name: "0"}
188+ d.sessions["b"] = &session{id: "b", name: "1"}
189+ require.Equal(t, "2", d.allocEphemeralNameLocked())
190+
191+ // Kill "0": the freed number is reused before "2".
192+ delete(d.sessions, "a")
193+ require.Equal(t, "0", d.allocEphemeralNameLocked())
194+}
195+
196+// --- attach replace ---------------------------------------------------------
197+
198+func TestAttachReplaceDetachesOld(t *testing.T) {
199+ p, releasePTY := newBlockingPTY(t)
200+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
201+
202+ // Client A creates and attaches to ephemeral "0".
203+ trA, sendsA, releaseA := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
204+ var hg sync.WaitGroup
205+ hg.Go(func() { d.handleConn(trA) })
206+ awaitFrame(t, sendsA, ports.MsgWelcome)
207+ awaitFrame(t, sendsA, ports.MsgOutput)
208+ sess := firstSession(d)
209+ require.NotNil(t, sess)
210+
211+ // Client B attaches to the same session, displacing A.
212+ trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24}))
213+ hg.Go(func() { d.handleConn(trB) })
214+ awaitFrame(t, sendsB, ports.MsgWelcome)
215+
216+ // A is notified it was detached.
217+ dA := awaitFrame(t, sendsA, ports.MsgDetached)
218+ det, _ := ports.UnmarshalDetached(dA.Payload)
219+ require.Equal(t, ports.ReasonDetach, det.Reason)
220+
221+ // B is now the current client.
222+ sess.mu.Lock()
223+ require.NotNil(t, sess.client)
224+ sess.mu.Unlock()
225+
226+ releaseA()
227+ releaseB()
228+ d.killSession(sess, ports.ReasonServerShutdown)
229+ releasePTY()
230+ hg.Wait()
231+ d.sessWg.Wait()
232+ d.waitNotifies()
233+}
234+
235+// --- detach semantics -------------------------------------------------------
236+
237+func TestDetachKillsEphemeral(t *testing.T) {
238+ p, releasePTY := newBlockingPTY(t)
239+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
240+ tr, sends, _ := newConn(t,
241+ mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}),
242+ ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})},
243+ )
244+
245+ var hg sync.WaitGroup
246+ hg.Go(func() { d.handleConn(tr) })
247+ awaitFrame(t, sends, ports.MsgWelcome)
248+
249+ require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond,
250+ "ephemeral session must die on client detach")
251+
252+ releasePTY()
253+ hg.Wait()
254+ d.sessWg.Wait()
255+}
256+
257+func TestDetachKeepsNamed(t *testing.T) {
258+ p, releasePTY := newBlockingPTY(t)
259+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
260+ tr, sends, _ := newConn(t,
261+ mustHello(ports.IntentNew, "keep", domain.Size{Cols: 80, Rows: 24}),
262+ ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})},
263+ )
264+
265+ var hg sync.WaitGroup
266+ hg.Go(func() { d.handleConn(tr) })
267+ awaitFrame(t, sends, ports.MsgWelcome)
268+ awaitFrame(t, sends, ports.MsgDetached) // ack for explicit detach
269+ hg.Wait() // handler returns after detach
270+
271+ require.Equal(t, 1, sessionCount(d), "named session survives detach")
272+ sess := firstSession(d)
273+ sess.mu.Lock()
274+ require.Nil(t, sess.client, "named session is headless after detach")
275+ sess.mu.Unlock()
276+
277+ d.killSession(sess, ports.ReasonServerShutdown)
278+ releasePTY()
279+ d.sessWg.Wait()
280+ d.waitNotifies()
281+}
282+
283+// --- render scheduler debounce ----------------------------------------------
284+
285+func TestReaderEOFRemovesSessionAndSignalsShutdown(t *testing.T) {
286+ p := portsmocks.NewMockPTY(t)
287+ // Read returns EOF immediately (child already gone).
288+ p.EXPECT().Read(mock.Anything).Return(0, io.EOF).Maybe()
289+ p.EXPECT().Close().Return(nil).Maybe()
290+ p.EXPECT().Pid().Return(1).Maybe()
291+
292+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
293+ tr, sends, _ := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
294+
295+ var hg sync.WaitGroup
296+ hg.Go(func() { d.handleConn(tr) })
297+
298+ // The client is detached with ReasonSessionKilled when the child exits.
299+ det := awaitFrame(t, sends, ports.MsgDetached)
300+ dm, _ := ports.UnmarshalDetached(det.Payload)
301+ require.Equal(t, ports.ReasonSessionKilled, dm.Reason)
302+
303+ select {
304+ case <-d.done:
305+ case <-time.After(2 * time.Second):
306+ t.Fatal("registry-empty shutdown was not signalled")
307+ }
308+ require.Equal(t, 0, sessionCount(d))
309+
310+ hg.Wait()
311+ d.sessWg.Wait()
312+ d.waitNotifies()
313+}
314+
315+// --- full Serve lifecycle ---------------------------------------------------
316+
317+func TestServeReturnsWhenLastSessionExits(t *testing.T) {
318+ p, releasePTY := newBlockingPTY(t)
319+ tr, sends, _ := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
320+
321+ l := portsmocks.NewMockListener(t)
322+ connCh := make(chan ports.Transport, 1)
323+ connCh <- tr
324+ closed := make(chan struct{})
325+ var once sync.Once
326+ l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {
327+ select {
328+ case c := <-connCh:
329+ return c, nil
330+ case <-closed:
331+ return nil, io.EOF
332+ }
333+ }).Maybe()
334+ l.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil }).Maybe()
335+ l.EXPECT().Addr().Return("mock").Maybe()
336+
337+ d := New(newFactory(t, p), stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
338+
339+ served := make(chan error, 1)
340+ go func() { served <- d.Serve(context.Background(), l) }()
341+
342+ awaitFrame(t, sends, ports.MsgWelcome)
343+ awaitFrame(t, sends, ports.MsgOutput)
344+
345+ // Child exits -> session removed -> registry empties -> Serve returns.
346+ releasePTY()
347+
348+ select {
349+ case err := <-served:
350+ require.NoError(t, err)
351+ case <-time.After(3 * time.Second):
352+ t.Fatal("Serve did not return after last session exited")
353+ }
354+}
355+
356+func TestServeGracefulShutdownOnContextCancel(t *testing.T) {
357+ p, releasePTY := newBlockingPTY(t)
358+ defer releasePTY()
359+ tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "long", domain.Size{Cols: 80, Rows: 24}))
360+
361+ l := portsmocks.NewMockListener(t)
362+ connCh := make(chan ports.Transport, 1)
363+ connCh <- tr
364+ closed := make(chan struct{})
365+ var once sync.Once
366+ l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {
367+ select {
368+ case c := <-connCh:
369+ return c, nil
370+ case <-closed:
371+ return nil, io.EOF
372+ }
373+ }).Maybe()
374+ l.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil }).Maybe()
375+ l.EXPECT().Addr().Return("mock").Maybe()
376+
377+ d := New(newFactory(t, p), stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
378+ ctx, cancel := context.WithCancel(context.Background())
379+
380+ served := make(chan error, 1)
381+ go func() { served <- d.Serve(ctx, l) }()
382+
383+ awaitFrame(t, sends, ports.MsgWelcome)
384+ awaitFrame(t, sends, ports.MsgOutput)
385+
386+ cancel() // graceful shutdown
387+
388+ det := awaitFrame(t, sends, ports.MsgDetached)
389+ dm, _ := ports.UnmarshalDetached(det.Payload)
390+ require.Equal(t, ports.ReasonServerShutdown, dm.Reason)
391+
392+ select {
393+ case err := <-served:
394+ require.NoError(t, err)
395+ case <-time.After(3 * time.Second):
396+ t.Fatal("Serve did not return after context cancel")
397+ }
398+}
399+
400+// --- shutdown/create interlock ------------------------------------------------
401+
402+// TestHelloRacingShutdownIsRejected covers the interlock between the
403+// registry-empty shutdown decision and session creation: once killSession has
404+// removed the last session (making shutdown irreversible via doneOnce), a
405+// Hello landing at any later instant must be rejected cleanly — never spawn a
406+// child that no teardown pass would reap. The interleaving is deterministic:
407+// killSession runs to completion before the racing Hellos are handled, which
408+// models the worst case (insertion attempt after the shutdown snapshot).
409+func TestHelloRacingShutdownIsRejected(t *testing.T) {
410+ p, releasePTY := newBlockingPTY(t)
411+ f := portsmocks.NewMockPTYFactory(t)
412+ // Exactly one child may ever be spawned; a second Open call (a leaked
413+ // child) fails the test via mock expectations.
414+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Once()
415+ d := newTestDaemon(t, f, stubClock{})
416+
417+ tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
418+ var hg sync.WaitGroup
419+ hg.Go(func() { d.handleConn(tr1) })
420+ awaitFrame(t, sends1, ports.MsgWelcome)
421+ sess := firstSession(d)
422+ require.NotNil(t, sess)
423+
424+ // The last session dies: shutdown begins irreversibly.
425+ d.killSession(sess, ports.ReasonSessionKilled)
426+ select {
427+ case <-d.done:
428+ default:
429+ t.Fatal("registry-empty shutdown must be signalled synchronously by killSession")
430+ }
431+
432+ // Racing Hellos — both creation intents must be rejected with a clean
433+ // typed error, and nothing may be inserted into the registry.
434+ for _, intent := range []struct {
435+ name string
436+ intent uint8
437+ sess string
438+ }{
439+ {"ephemeral", ports.IntentEphemeral, ""},
440+ {"named", ports.IntentNew, "latecomer"},
441+ } {
442+ tr2, sends2, _ := newConn(t, mustHello(intent.intent, intent.sess, domain.Size{Cols: 80, Rows: 24}))
443+ d.handleConn(tr2)
444+ e := awaitFrame(t, sends2, ports.MsgError)
445+ em, err := ports.UnmarshalErrorMsg(e.Payload)
446+ require.NoError(t, err)
447+ require.Equal(t, ports.ErrServerShutdown, em.Code, "%s hello racing shutdown must be rejected", intent.name)
448+ }
449+ require.Equal(t, 0, sessionCount(d), "no session may be inserted after shutdown began")
450+
451+ release1()
452+ releasePTY()
453+ hg.Wait()
454+ d.sessWg.Wait()
455+ d.waitNotifies()
456+}
457+
458+// --- wedged-client teardown ---------------------------------------------------
459+
460+// notifyClock drives the daemon with a stub debounce timer (schedulers park)
461+// but a short real timer for the detach-notify deadline, so a wedged client's
462+
463+func TestServeReturnsDespiteWedgedClientOnShutdown(t *testing.T) {
464+ p, _ := newBlockingPTY(t) // Close unblocks the parked reader
465+
466+ tr := portsmocks.NewMockTransport(t)
467+ sends := make(chan ports.Frame, 64)
468+ recvCh := make(chan ports.Frame, 1)
469+ recvCh <- mustHello(ports.IntentNew, "wedge", domain.Size{Cols: 80, Rows: 24})
470+ connDone := make(chan struct{})
471+ var connOnce sync.Once
472+ closeConn := func() { connOnce.Do(func() { close(connDone) }) }
473+
474+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
475+ select {
476+ case f := <-recvCh:
477+ return f, nil
478+ case <-connDone:
479+ return ports.Frame{}, io.EOF
480+ }
481+ }).Maybe()
482+ tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
483+ if f.Type == ports.MsgDetached {
484+ // Wedged: blocks until the transport is force-closed.
485+ <-connDone
486+ return io.ErrClosedPipe
487+ }
488+ sends <- f
489+ return nil
490+ }).Maybe()
491+ tr.EXPECT().Close().RunAndReturn(func() error { closeConn(); return nil }).Maybe()
492+
493+ l := portsmocks.NewMockListener(t)
494+ connCh := make(chan ports.Transport, 1)
495+ connCh <- tr
496+ lnClosed := make(chan struct{})
497+ var lnOnce sync.Once
498+ l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {
499+ select {
500+ case c := <-connCh:
501+ return c, nil
502+ case <-lnClosed:
503+ return nil, io.EOF
504+ }
505+ }).Maybe()
506+ l.EXPECT().Close().RunAndReturn(func() error { lnOnce.Do(func() { close(lnClosed) }); return nil }).Maybe()
507+ l.EXPECT().Addr().Return("mock").Maybe()
508+
509+ d := New(newFactory(t, p), notifyClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
510+ ctx, cancel := context.WithCancel(context.Background())
511+
512+ served := make(chan error, 1)
513+ go func() { served <- d.Serve(ctx, l) }()
514+
515+ awaitFrame(t, sends, ports.MsgWelcome)
516+ awaitFrame(t, sends, ports.MsgOutput)
517+
518+ cancel() // shutdown with the wedged client still attached
519+
520+ select {
521+ case err := <-served:
522+ require.NoError(t, err)
523+ case <-time.After(3 * time.Second):
524+ t.Fatal("Serve hung on a wedged client during teardown")
525+ }
526+ require.Equal(t, 0, sessionCount(d), "session must be torn down despite the wedged client")
527+}
528+
529+// --- send-error kills ephemeral -----------------------------------------------
530+
531+// TestSendErrorKillsEphemeral: a failed output send detaches the client, and —
532+// like every other detach path — that kills an ephemeral session rather than
@@ -0,0 +1,532 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
17+)
18+
19+// --- test doubles -----------------------------------------------------------
20+
21+// stubClock returns timers whose channel never fires, so a scheduler under it
22+// blocks in its debounce loop until the session context is cancelled. Used by
23+
24+func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
25+ sz := domain.Size{Cols: 80, Rows: 24}
26+ first, releaseFirst := newBlockingPTY(t)
27+ defer releaseFirst()
28+ second, releaseSecond := newBlockingPTY(t)
29+ defer releaseSecond()
30+ f := portsmocks.NewMockPTYFactory(t)
31+ var dirs []string
32+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
33+ func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
34+ dirs = append(dirs, dir)
35+ if len(dirs) == 1 {
36+ return first, nil
37+ }
38+ return second, nil
39+ },
40+ ).Twice()
41+
42+ d := newTestDaemon(t, f, stubClock{})
43+ tr := portsmocks.NewMockTransport(t)
44+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
45+ tr.EXPECT().Close().Return(nil).Maybe()
46+
47+ sess, ac, err := d.route(ports.Hello{
48+ Version: ports.ProtocolVersion,
49+ Intent: ports.IntentNew,
50+ Name: "work",
51+ Size: sz,
52+ TermEnv: "xterm-256color",
53+ Cwd: "/tmp/work",
54+ }, tr)
55+ require.NoError(t, err)
56+ require.NotNil(t, ac)
57+ require.Equal(t, "/tmp/work", sess.cwd)
58+
59+ require.NoError(t, d.createTab(sess, sz))
60+ require.Equal(t, []string{"/tmp/work", "/tmp/work"}, dirs)
61+}
62+
63+func TestHandshakeEphemeralHappy(t *testing.T) {
64+ p, releasePTY := newBlockingPTY(t)
65+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
66+ tr, sends, releaseConn := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
67+
68+ var hg sync.WaitGroup
69+ hg.Go(func() { d.handleConn(tr) })
70+
71+ w := awaitFrame(t, sends, ports.MsgWelcome)
72+ welcome, err := ports.UnmarshalWelcome(w.Payload)
73+ require.NoError(t, err)
74+ require.Equal(t, "0", welcome.SessionName)
75+ require.True(t, welcome.Ephemeral)
76+ awaitFrame(t, sends, ports.MsgOutput) // guaranteed first paint
77+ require.Equal(t, 1, sessionCount(d))
78+
79+ releaseConn()
80+ releasePTY()
81+ hg.Wait()
82+ d.sessWg.Wait()
83+}
84+
85+func TestHandshakeNewHappy(t *testing.T) {
86+ p, releasePTY := newBlockingPTY(t)
87+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
88+ tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
89+
90+ var hg sync.WaitGroup
91+ hg.Go(func() { d.handleConn(tr) })
92+
93+ w := awaitFrame(t, sends, ports.MsgWelcome)
94+ welcome, err := ports.UnmarshalWelcome(w.Payload)
95+ require.NoError(t, err)
96+ require.Equal(t, "work", welcome.SessionName)
97+ require.False(t, welcome.Ephemeral)
98+
99+ sess := firstSession(d)
100+ require.NotNil(t, sess)
101+
102+ releaseConn()
103+ d.killSession(sess, ports.ReasonServerShutdown)
104+ releasePTY()
105+ hg.Wait()
106+ d.sessWg.Wait()
107+ d.waitNotifies()
108+}
109+
110+func TestHandshakeVersionMismatch(t *testing.T) {
111+ // No Open expectation: the factory must never be asked to spawn a PTY.
112+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
113+ bad := ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{
114+ Version: ports.ProtocolVersion + 99, Intent: ports.IntentEphemeral, Size: domain.Size{Cols: 80, Rows: 24},
115+ })}
116+ tr, sends, _ := newConn(t, bad)
117+
118+ d.handleConn(tr) // returns after the rejection; no session, no goroutines
119+
120+ e := awaitFrame(t, sends, ports.MsgError)
121+ em, err := ports.UnmarshalErrorMsg(e.Payload)
122+ require.NoError(t, err)
123+ require.Equal(t, ports.ErrVersionMismatch, em.Code)
124+ require.Equal(t, 0, sessionCount(d))
125+}
126+
127+func TestHandshakeOldHelloLayoutReportsVersionMismatch(t *testing.T) {
128+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
129+ oldLayout := []byte{
130+ 0x00, byte(ports.ProtocolVersion - 1),
131+ ports.IntentEphemeral,
132+ 0x00, 0x00,
133+ 0x00, 0x50,
134+ 0x00, 0x18,
135+ 0x00, 0x00,
136+ }
137+ tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: oldLayout})
138+ d.handleConn(tr)
139+
140+ e := awaitFrame(t, sends, ports.MsgError)
141+ em, err := ports.UnmarshalErrorMsg(e.Payload)
142+ require.NoError(t, err)
143+ require.Equal(t, ports.ErrVersionMismatch, em.Code)
144+ require.Equal(t, 0, sessionCount(d))
145+}
146+
147+func TestHandshakeNameTaken(t *testing.T) {
148+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
149+ // Pre-populate a bare session (no goroutines) to collide with.
150+ d.sessions[domain.SessionID("x")] = &session{id: "x", name: "work"}
151+
152+ tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
153+ d.handleConn(tr)
154+
155+ e := awaitFrame(t, sends, ports.MsgError)
156+ em, _ := ports.UnmarshalErrorMsg(e.Payload)
157+ require.Equal(t, ports.ErrNameTaken, em.Code)
158+}
159+
160+func TestHandshakeNoSuchSession(t *testing.T) {
161+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
162+ tr, sends, _ := newConn(t, mustHello(ports.IntentAttach, "ghost", domain.Size{Cols: 80, Rows: 24}))
163+ d.handleConn(tr)
164+
165+ e := awaitFrame(t, sends, ports.MsgError)
166+ em, _ := ports.UnmarshalErrorMsg(e.Payload)
167+ require.Equal(t, ports.ErrNoSuchSession, em.Code)
168+}
169+
170+func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) {
171+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
172+ tr, _, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})})
173+ d.handleConn(tr)
174+
175+ select {
176+ case <-d.done:
177+ case <-time.After(time.Second):
178+ t.Fatal("kill all on empty daemon did not signal shutdown")
179+ }
180+}
181+
182+// --- ephemeral numbering ----------------------------------------------------
183+
184+func TestEphemeralNumberingReuse(t *testing.T) {
185+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
186+ // Emulate two ephemeral sessions "0" and "1".
187+ d.sessions["a"] = &session{id: "a", name: "0"}
188+ d.sessions["b"] = &session{id: "b", name: "1"}
189+ require.Equal(t, "2", d.allocEphemeralNameLocked())
190+
191+ // Kill "0": the freed number is reused before "2".
192+ delete(d.sessions, "a")
193+ require.Equal(t, "0", d.allocEphemeralNameLocked())
194+}
195+
196+// --- attach replace ---------------------------------------------------------
197+
198+func TestAttachReplaceDetachesOld(t *testing.T) {
199+ p, releasePTY := newBlockingPTY(t)
200+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
201+
202+ // Client A creates and attaches to ephemeral "0".
203+ trA, sendsA, releaseA := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
204+ var hg sync.WaitGroup
205+ hg.Go(func() { d.handleConn(trA) })
206+ awaitFrame(t, sendsA, ports.MsgWelcome)
207+ awaitFrame(t, sendsA, ports.MsgOutput)
208+ sess := firstSession(d)
209+ require.NotNil(t, sess)
210+
211+ // Client B attaches to the same session, displacing A.
212+ trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24}))
213+ hg.Go(func() { d.handleConn(trB) })
214+ awaitFrame(t, sendsB, ports.MsgWelcome)
215+
216+ // A is notified it was detached.
217+ dA := awaitFrame(t, sendsA, ports.MsgDetached)
218+ det, _ := ports.UnmarshalDetached(dA.Payload)
219+ require.Equal(t, ports.ReasonDetach, det.Reason)
220+
221+ // B is now the current client.
222+ sess.mu.Lock()
223+ require.NotNil(t, sess.client)
224+ sess.mu.Unlock()
225+
226+ releaseA()
227+ releaseB()
228+ d.killSession(sess, ports.ReasonServerShutdown)
229+ releasePTY()
230+ hg.Wait()
231+ d.sessWg.Wait()
232+ d.waitNotifies()
233+}
234+
235+// --- detach semantics -------------------------------------------------------
236+
237+func TestDetachKillsEphemeral(t *testing.T) {
238+ p, releasePTY := newBlockingPTY(t)
239+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
240+ tr, sends, _ := newConn(t,
241+ mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}),
242+ ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})},
243+ )
244+
245+ var hg sync.WaitGroup
246+ hg.Go(func() { d.handleConn(tr) })
247+ awaitFrame(t, sends, ports.MsgWelcome)
248+
249+ require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond,
250+ "ephemeral session must die on client detach")
251+
252+ releasePTY()
253+ hg.Wait()
254+ d.sessWg.Wait()
255+}
256+
257+func TestDetachKeepsNamed(t *testing.T) {
258+ p, releasePTY := newBlockingPTY(t)
259+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
260+ tr, sends, _ := newConn(t,
261+ mustHello(ports.IntentNew, "keep", domain.Size{Cols: 80, Rows: 24}),
262+ ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})},
263+ )
264+
265+ var hg sync.WaitGroup
266+ hg.Go(func() { d.handleConn(tr) })
267+ awaitFrame(t, sends, ports.MsgWelcome)
268+ awaitFrame(t, sends, ports.MsgDetached) // ack for explicit detach
269+ hg.Wait() // handler returns after detach
270+
271+ require.Equal(t, 1, sessionCount(d), "named session survives detach")
272+ sess := firstSession(d)
273+ sess.mu.Lock()
274+ require.Nil(t, sess.client, "named session is headless after detach")
275+ sess.mu.Unlock()
276+
277+ d.killSession(sess, ports.ReasonServerShutdown)
278+ releasePTY()
279+ d.sessWg.Wait()
280+ d.waitNotifies()
281+}
282+
283+// --- render scheduler debounce ----------------------------------------------
284+
285+func TestReaderEOFRemovesSessionAndSignalsShutdown(t *testing.T) {
286+ p := portsmocks.NewMockPTY(t)
287+ // Read returns EOF immediately (child already gone).
288+ p.EXPECT().Read(mock.Anything).Return(0, io.EOF).Maybe()
289+ p.EXPECT().Close().Return(nil).Maybe()
290+ p.EXPECT().Pid().Return(1).Maybe()
291+
292+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
293+ tr, sends, _ := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
294+
295+ var hg sync.WaitGroup
296+ hg.Go(func() { d.handleConn(tr) })
297+
298+ // The client is detached with ReasonSessionKilled when the child exits.
299+ det := awaitFrame(t, sends, ports.MsgDetached)
300+ dm, _ := ports.UnmarshalDetached(det.Payload)
301+ require.Equal(t, ports.ReasonSessionKilled, dm.Reason)
302+
303+ select {
304+ case <-d.done:
305+ case <-time.After(2 * time.Second):
306+ t.Fatal("registry-empty shutdown was not signalled")
307+ }
308+ require.Equal(t, 0, sessionCount(d))
309+
310+ hg.Wait()
311+ d.sessWg.Wait()
312+ d.waitNotifies()
313+}
314+
315+// --- full Serve lifecycle ---------------------------------------------------
316+
317+func TestServeReturnsWhenLastSessionExits(t *testing.T) {
318+ p, releasePTY := newBlockingPTY(t)
319+ tr, sends, _ := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
320+
321+ l := portsmocks.NewMockListener(t)
322+ connCh := make(chan ports.Transport, 1)
323+ connCh <- tr
324+ closed := make(chan struct{})
325+ var once sync.Once
326+ l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {
327+ select {
328+ case c := <-connCh:
329+ return c, nil
330+ case <-closed:
331+ return nil, io.EOF
332+ }
333+ }).Maybe()
334+ l.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil }).Maybe()
335+ l.EXPECT().Addr().Return("mock").Maybe()
336+
337+ d := New(newFactory(t, p), stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
338+
339+ served := make(chan error, 1)
340+ go func() { served <- d.Serve(context.Background(), l) }()
341+
342+ awaitFrame(t, sends, ports.MsgWelcome)
343+ awaitFrame(t, sends, ports.MsgOutput)
344+
345+ // Child exits -> session removed -> registry empties -> Serve returns.
346+ releasePTY()
347+
348+ select {
349+ case err := <-served:
350+ require.NoError(t, err)
351+ case <-time.After(3 * time.Second):
352+ t.Fatal("Serve did not return after last session exited")
353+ }
354+}
355+
356+func TestServeGracefulShutdownOnContextCancel(t *testing.T) {
357+ p, releasePTY := newBlockingPTY(t)
358+ defer releasePTY()
359+ tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "long", domain.Size{Cols: 80, Rows: 24}))
360+
361+ l := portsmocks.NewMockListener(t)
362+ connCh := make(chan ports.Transport, 1)
363+ connCh <- tr
364+ closed := make(chan struct{})
365+ var once sync.Once
366+ l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {
367+ select {
368+ case c := <-connCh:
369+ return c, nil
370+ case <-closed:
371+ return nil, io.EOF
372+ }
373+ }).Maybe()
374+ l.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil }).Maybe()
375+ l.EXPECT().Addr().Return("mock").Maybe()
376+
377+ d := New(newFactory(t, p), stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
378+ ctx, cancel := context.WithCancel(context.Background())
379+
380+ served := make(chan error, 1)
381+ go func() { served <- d.Serve(ctx, l) }()
382+
383+ awaitFrame(t, sends, ports.MsgWelcome)
384+ awaitFrame(t, sends, ports.MsgOutput)
385+
386+ cancel() // graceful shutdown
387+
388+ det := awaitFrame(t, sends, ports.MsgDetached)
389+ dm, _ := ports.UnmarshalDetached(det.Payload)
390+ require.Equal(t, ports.ReasonServerShutdown, dm.Reason)
391+
392+ select {
393+ case err := <-served:
394+ require.NoError(t, err)
395+ case <-time.After(3 * time.Second):
396+ t.Fatal("Serve did not return after context cancel")
397+ }
398+}
399+
400+// --- shutdown/create interlock ------------------------------------------------
401+
402+// TestHelloRacingShutdownIsRejected covers the interlock between the
403+// registry-empty shutdown decision and session creation: once killSession has
404+// removed the last session (making shutdown irreversible via doneOnce), a
405+// Hello landing at any later instant must be rejected cleanly — never spawn a
406+// child that no teardown pass would reap. The interleaving is deterministic:
407+// killSession runs to completion before the racing Hellos are handled, which
408+// models the worst case (insertion attempt after the shutdown snapshot).
409+func TestHelloRacingShutdownIsRejected(t *testing.T) {
410+ p, releasePTY := newBlockingPTY(t)
411+ f := portsmocks.NewMockPTYFactory(t)
412+ // Exactly one child may ever be spawned; a second Open call (a leaked
413+ // child) fails the test via mock expectations.
414+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Once()
415+ d := newTestDaemon(t, f, stubClock{})
416+
417+ tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
418+ var hg sync.WaitGroup
419+ hg.Go(func() { d.handleConn(tr1) })
420+ awaitFrame(t, sends1, ports.MsgWelcome)
421+ sess := firstSession(d)
422+ require.NotNil(t, sess)
423+
424+ // The last session dies: shutdown begins irreversibly.
425+ d.killSession(sess, ports.ReasonSessionKilled)
426+ select {
427+ case <-d.done:
428+ default:
429+ t.Fatal("registry-empty shutdown must be signalled synchronously by killSession")
430+ }
431+
432+ // Racing Hellos — both creation intents must be rejected with a clean
433+ // typed error, and nothing may be inserted into the registry.
434+ for _, intent := range []struct {
435+ name string
436+ intent uint8
437+ sess string
438+ }{
439+ {"ephemeral", ports.IntentEphemeral, ""},
440+ {"named", ports.IntentNew, "latecomer"},
441+ } {
442+ tr2, sends2, _ := newConn(t, mustHello(intent.intent, intent.sess, domain.Size{Cols: 80, Rows: 24}))
443+ d.handleConn(tr2)
444+ e := awaitFrame(t, sends2, ports.MsgError)
445+ em, err := ports.UnmarshalErrorMsg(e.Payload)
446+ require.NoError(t, err)
447+ require.Equal(t, ports.ErrServerShutdown, em.Code, "%s hello racing shutdown must be rejected", intent.name)
448+ }
449+ require.Equal(t, 0, sessionCount(d), "no session may be inserted after shutdown began")
450+
451+ release1()
452+ releasePTY()
453+ hg.Wait()
454+ d.sessWg.Wait()
455+ d.waitNotifies()
456+}
457+
458+// --- wedged-client teardown ---------------------------------------------------
459+
460+// notifyClock drives the daemon with a stub debounce timer (schedulers park)
461+// but a short real timer for the detach-notify deadline, so a wedged client's
462+
463+func TestServeReturnsDespiteWedgedClientOnShutdown(t *testing.T) {
464+ p, _ := newBlockingPTY(t) // Close unblocks the parked reader
465+
466+ tr := portsmocks.NewMockTransport(t)
467+ sends := make(chan ports.Frame, 64)
468+ recvCh := make(chan ports.Frame, 1)
469+ recvCh <- mustHello(ports.IntentNew, "wedge", domain.Size{Cols: 80, Rows: 24})
470+ connDone := make(chan struct{})
471+ var connOnce sync.Once
472+ closeConn := func() { connOnce.Do(func() { close(connDone) }) }
473+
474+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
475+ select {
476+ case f := <-recvCh:
477+ return f, nil
478+ case <-connDone:
479+ return ports.Frame{}, io.EOF
480+ }
481+ }).Maybe()
482+ tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
483+ if f.Type == ports.MsgDetached {
484+ // Wedged: blocks until the transport is force-closed.
485+ <-connDone
486+ return io.ErrClosedPipe
487+ }
488+ sends <- f
489+ return nil
490+ }).Maybe()
491+ tr.EXPECT().Close().RunAndReturn(func() error { closeConn(); return nil }).Maybe()
492+
493+ l := portsmocks.NewMockListener(t)
494+ connCh := make(chan ports.Transport, 1)
495+ connCh <- tr
496+ lnClosed := make(chan struct{})
497+ var lnOnce sync.Once
498+ l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {
499+ select {
500+ case c := <-connCh:
501+ return c, nil
502+ case <-lnClosed:
503+ return nil, io.EOF
504+ }
505+ }).Maybe()
506+ l.EXPECT().Close().RunAndReturn(func() error { lnOnce.Do(func() { close(lnClosed) }); return nil }).Maybe()
507+ l.EXPECT().Addr().Return("mock").Maybe()
508+
509+ d := New(newFactory(t, p), notifyClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
510+ ctx, cancel := context.WithCancel(context.Background())
511+
512+ served := make(chan error, 1)
513+ go func() { served <- d.Serve(ctx, l) }()
514+
515+ awaitFrame(t, sends, ports.MsgWelcome)
516+ awaitFrame(t, sends, ports.MsgOutput)
517+
518+ cancel() // shutdown with the wedged client still attached
519+
520+ select {
521+ case err := <-served:
522+ require.NoError(t, err)
523+ case <-time.After(3 * time.Second):
524+ t.Fatal("Serve hung on a wedged client during teardown")
525+ }
526+ require.Equal(t, 0, sessionCount(d), "session must be torn down despite the wedged client")
527+}
528+
529+// --- send-error kills ephemeral -----------------------------------------------
530+
531+// TestSendErrorKillsEphemeral: a failed output send detaches the client, and —
532+// like every other detach path — that kills an ephemeral session rather than
A internal/usecase/daemon/status_test.go +98 -0
@@ -0,0 +1,98 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/pkg/renderer"
12+ "github.com/bnema/vev/pkg/vt"
13+)
14+
15+// --- test doubles -----------------------------------------------------------
16+
17+// stubClock returns timers whose channel never fires, so a scheduler under it
18+// blocks in its debounce loop until the session context is cancelled. Used by
19+
20+func TestStatusCompositionGolden(t *testing.T) {
21+ p1, releasePTY1 := newBlockingPTY(t)
22+ p2, releasePTY2 := newBlockingPTY(t)
23+ _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
24+ defer releasePTY1()
25+ defer releasePTY2()
26+ sess.active = 1
27+ sess.name = "work"
28+
29+ win := sess.activeTab()
30+ win.screen = vt.NewScreen(12, 2)
31+ win.size = domain.Size{Cols: 12, Rows: 2}
32+ win.screen.Write([]byte("hello"))
33+
34+ frame, damage := composeClientFrame(sess, win, true, "")
35+
36+ require.Equal(t, 12, frame.Width)
37+ require.Equal(t, 3, frame.Height)
38+ require.Equal(t, "hello ", rowText(frame.Row(0)))
39+ require.Equal(t, " work 1 2 ", rowText(frame.Row(2)))
40+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
41+ for i, c := range frame.Row(2) {
42+ if i >= len(" work 1 ") && i < len(" work 1 2 ") {
43+ require.True(t, c.Style.Inverse, "active tab segment cell %d should be inverse", i)
44+ }
45+ }
46+}
47+
48+func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
49+ p, releasePTY := newBlockingPTY(t)
50+ _, sess, _, _ := newManualSessionWithPTYs(t, p)
51+ defer releasePTY()
52+ sess.name = "work"
53+ win := sess.activeTab()
54+ win.screen = vt.NewScreen(30, 2)
55+ win.size = domain.Size{Cols: 30, Rows: 2}
56+
57+ frame, _ := composeClientFrame(sess, win, true, "ok")
58+ require.Equal(t, " work 1 ok", rowText(frame.Row(2)))
59+
60+ frame, _ = composeClientFrame(sess, win, true, "1234567890123456789")
61+ require.Equal(t, " work 1 1234567890123456789", rowText(frame.Row(2)))
62+
63+ frame, _ = composeClientFrame(sess, win, true, "selection too large to copy")
64+ require.Equal(t, " work 1 ", rowText(frame.Row(2)))
65+}
66+
67+func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
68+ p1, releasePTY1 := newBlockingPTY(t)
69+ p2, releasePTY2 := newBlockingPTY(t)
70+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
71+ tr, sends, releaseConn := newConn(t,
72+ mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}),
73+ frameInput([]byte("\x1bc")),
74+ frameInput([]byte("\x1b1")),
75+ ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 22, Rows: 6}})},
76+ )
77+
78+ var hg sync.WaitGroup
79+ hg.Go(func() { d.handleConn(tr) })
80+ awaitFrame(t, sends, ports.MsgWelcome)
81+ first := awaitFrame(t, sends, ports.MsgOutput)
82+ created := awaitFrame(t, sends, ports.MsgOutput)
83+ switched := awaitFrame(t, sends, ports.MsgOutput)
84+ resized := awaitFrame(t, sends, ports.MsgOutput)
85+
86+ for _, f := range []ports.Frame{first, created, switched, resized} {
87+ out, err := ports.UnmarshalOutput(f.Payload)
88+ require.NoError(t, err)
89+ require.Contains(t, string(out.Data), "work")
90+ require.Contains(t, string(out.Data), ";7m", "active status tab should be inverse-highlighted")
91+ }
92+
93+ releaseConn()
94+ releasePTY1()
95+ releasePTY2()
96+ hg.Wait()
97+ d.sessWg.Wait()
98+}
@@ -0,0 +1,98 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/pkg/renderer"
12+ "github.com/bnema/vev/pkg/vt"
13+)
14+
15+// --- test doubles -----------------------------------------------------------
16+
17+// stubClock returns timers whose channel never fires, so a scheduler under it
18+// blocks in its debounce loop until the session context is cancelled. Used by
19+
20+func TestStatusCompositionGolden(t *testing.T) {
21+ p1, releasePTY1 := newBlockingPTY(t)
22+ p2, releasePTY2 := newBlockingPTY(t)
23+ _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
24+ defer releasePTY1()
25+ defer releasePTY2()
26+ sess.active = 1
27+ sess.name = "work"
28+
29+ win := sess.activeTab()
30+ win.screen = vt.NewScreen(12, 2)
31+ win.size = domain.Size{Cols: 12, Rows: 2}
32+ win.screen.Write([]byte("hello"))
33+
34+ frame, damage := composeClientFrame(sess, win, true, "")
35+
36+ require.Equal(t, 12, frame.Width)
37+ require.Equal(t, 3, frame.Height)
38+ require.Equal(t, "hello ", rowText(frame.Row(0)))
39+ require.Equal(t, " work 1 2 ", rowText(frame.Row(2)))
40+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
41+ for i, c := range frame.Row(2) {
42+ if i >= len(" work 1 ") && i < len(" work 1 2 ") {
43+ require.True(t, c.Style.Inverse, "active tab segment cell %d should be inverse", i)
44+ }
45+ }
46+}
47+
48+func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
49+ p, releasePTY := newBlockingPTY(t)
50+ _, sess, _, _ := newManualSessionWithPTYs(t, p)
51+ defer releasePTY()
52+ sess.name = "work"
53+ win := sess.activeTab()
54+ win.screen = vt.NewScreen(30, 2)
55+ win.size = domain.Size{Cols: 30, Rows: 2}
56+
57+ frame, _ := composeClientFrame(sess, win, true, "ok")
58+ require.Equal(t, " work 1 ok", rowText(frame.Row(2)))
59+
60+ frame, _ = composeClientFrame(sess, win, true, "1234567890123456789")
61+ require.Equal(t, " work 1 1234567890123456789", rowText(frame.Row(2)))
62+
63+ frame, _ = composeClientFrame(sess, win, true, "selection too large to copy")
64+ require.Equal(t, " work 1 ", rowText(frame.Row(2)))
65+}
66+
67+func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
68+ p1, releasePTY1 := newBlockingPTY(t)
69+ p2, releasePTY2 := newBlockingPTY(t)
70+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
71+ tr, sends, releaseConn := newConn(t,
72+ mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}),
73+ frameInput([]byte("\x1bc")),
74+ frameInput([]byte("\x1b1")),
75+ ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 22, Rows: 6}})},
76+ )
77+
78+ var hg sync.WaitGroup
79+ hg.Go(func() { d.handleConn(tr) })
80+ awaitFrame(t, sends, ports.MsgWelcome)
81+ first := awaitFrame(t, sends, ports.MsgOutput)
82+ created := awaitFrame(t, sends, ports.MsgOutput)
83+ switched := awaitFrame(t, sends, ports.MsgOutput)
84+ resized := awaitFrame(t, sends, ports.MsgOutput)
85+
86+ for _, f := range []ports.Frame{first, created, switched, resized} {
87+ out, err := ports.UnmarshalOutput(f.Payload)
88+ require.NoError(t, err)
89+ require.Contains(t, string(out.Data), "work")
90+ require.Contains(t, string(out.Data), ";7m", "active status tab should be inverse-highlighted")
91+ }
92+
93+ releaseConn()
94+ releasePTY1()
95+ releasePTY2()
96+ hg.Wait()
97+ d.sessWg.Wait()
98+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close