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+}
M internal/usecase/daemon/daemon_test.go +21 -2062
@@ -1,16 +1,10 @@
11 package daemon
22
33 import (
4- "bytes"
54 "context"
6- "encoding/base64"
7- "errors"
8- "fmt"
95 "io"
106 "log/slog"
11- "strings"
127 "sync"
13- "sync/atomic"
148 "testing"
159 "time"
1610
@@ -20,7 +14,6 @@ import (
2014 "github.com/bnema/vev/internal/domain"
2115 "github.com/bnema/vev/internal/ports"
2216 portsmocks "github.com/bnema/vev/internal/ports/mocks"
23- scopy "github.com/bnema/vev/internal/usecase/copy"
2417 "github.com/bnema/vev/internal/usecase/keys"
2518 "github.com/bnema/vev/pkg/renderer"
2619 "github.com/bnema/vev/pkg/vt"
@@ -176,45 +169,6 @@ func mustHello(intent uint8, name string, sz domain.Size) ports.Frame {
176169 })}
177170 }
178171
179-func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
180- sz := domain.Size{Cols: 80, Rows: 24}
181- first, releaseFirst := newBlockingPTY(t)
182- defer releaseFirst()
183- second, releaseSecond := newBlockingPTY(t)
184- defer releaseSecond()
185- f := portsmocks.NewMockPTYFactory(t)
186- var dirs []string
187- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
188- func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
189- dirs = append(dirs, dir)
190- if len(dirs) == 1 {
191- return first, nil
192- }
193- return second, nil
194- },
195- ).Twice()
196-
197- d := newTestDaemon(t, f, stubClock{})
198- tr := portsmocks.NewMockTransport(t)
199- tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
200- tr.EXPECT().Close().Return(nil).Maybe()
201-
202- sess, ac, err := d.route(ports.Hello{
203- Version: ports.ProtocolVersion,
204- Intent: ports.IntentNew,
205- Name: "work",
206- Size: sz,
207- TermEnv: "xterm-256color",
208- Cwd: "/tmp/work",
209- }, tr)
210- require.NoError(t, err)
211- require.NotNil(t, ac)
212- require.Equal(t, "/tmp/work", sess.cwd)
213-
214- require.NoError(t, d.createTab(sess, sz))
215- require.Equal(t, []string{"/tmp/work", "/tmp/work"}, dirs)
216-}
217-
218172 func frameInput(data []byte) ports.Frame {
219173 return ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: data})}
220174 }
@@ -299,25 +253,6 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio
299253 return d, sess, ac, sends
300254 }
301255
302-func TestPTYWriteErrorIsLogged(t *testing.T) {
303- var logs bytes.Buffer
304- d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logs, nil)))
305- errBoom := errors.New("boom")
306- p := portsmocks.NewMockPTY(t)
307- p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once()
308- win := &tab{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}}
309- sess := &session{id: "manual", name: "work", tabs: []*tab{win}}
310- ac := &attachedClient{}
311- ac.setSession(sess)
312-
313- daemonKeyHandler{d: d, ac: ac}.Forward([]byte("input"))
314-
315- got := logs.String()
316- if !strings.Contains(got, "pty write failed") || !strings.Contains(got, "boom") || !strings.Contains(got, "work") {
317- t.Fatalf("log output %q does not contain PTY write failure details", got)
318- }
319-}
320-
321256 func newManualTabSession(t *testing.T, n int) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
322257 t.Helper()
323258 ptys := make([]ports.PTY, 0, n)
@@ -345,353 +280,6 @@ func testRow(text string) []renderer.Cell {
345280 return cells
346281 }
347282
348-func TestScrollbackModeAltUInterceptsAndDoesNotForward(t *testing.T) {
349- writes := make(chan []byte, 1)
350- p, _ := newBlockingPTYWithWrites(t, writes)
351- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
352- sess.tabs[0].scrollback = scopy.NewScrollback(4)
353- sess.tabs[0].screen.Write([]byte("live"))
354-
355- d.handleInput(sess, ac, []byte("\x1bu"))
356-
357- if ac.copyMode == nil {
358- t.Fatal("scrollback mode not entered")
359- }
360- select {
361- case got := <-writes:
362- t.Fatalf("scrollback binding forwarded to PTY: %q", got)
363- default:
364- }
365- out := awaitFrame(t, sends, ports.MsgOutput)
366- msg, err := ports.UnmarshalOutput(out.Payload)
367- require.NoError(t, err)
368- if got := string(msg.Data); !strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[COPY]") {
369- t.Fatalf("scrollback mode paint = %q, want [SCROLL] without [COPY]", got)
370- }
371-
372- d.handleInput(sess, ac, []byte(" "))
373- out = awaitFrame(t, sends, ports.MsgOutput)
374- msg, err = ports.UnmarshalOutput(out.Payload)
375- require.NoError(t, err)
376- if got := string(msg.Data); !strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[SCROLL]") {
377- t.Fatalf("visual selection paint = %q, want [VISUAL] without [SCROLL]", got)
378- }
379-}
380-
381-func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
382- writes := make(chan []byte, 1)
383- p, _ := newBlockingPTYWithWrites(t, writes)
384- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
385- sess.tabs[0].scrollback = scopy.NewScrollback(4)
386- sess.tabs[0].scrollback.Append(testRow("old1 "))
387- sess.tabs[0].scrollback.Append(testRow("old2 "))
388- sess.tabs[0].screen.Write([]byte("live"))
389-
390- d.enterCopyMode(sess, ac)
391- awaitFrame(t, sends, ports.MsgOutput)
392- d.handleInput(sess, ac, []byte{'g', ' ', 'j', 'y'})
393-
394- select {
395- case got := <-writes:
396- t.Fatalf("copy-mode navigation forwarded to PTY: %q", got)
397- default:
398- }
399- out := awaitFrame(t, sends, ports.MsgOutput)
400- msg, err := ports.UnmarshalOutput(out.Payload)
401- require.NoError(t, err)
402- if got, want := string(msg.Data), "\x1b]52;c;b2xkMQpvbGQy\x07"; got != want {
403- t.Fatalf("OSC52 = %q, want %q", got, want)
404- }
405- if ac.copyMode != nil {
406- t.Fatal("copy mode still active after yank")
407- }
408- live := awaitFrame(t, sends, ports.MsgOutput)
409- liveMsg, err := ports.UnmarshalOutput(live.Payload)
410- require.NoError(t, err)
411- if strings.Contains(string(liveMsg.Data), "[COPY]") || strings.Contains(string(liveMsg.Data), "[SCROLL]") {
412- t.Fatalf("live repaint still contains copy/scroll status: %q", string(liveMsg.Data))
413- }
414- if !strings.Contains(string(liveMsg.Data), "copied 9 chars to clipboard") {
415- t.Fatalf("live repaint = %q, want copy success feedback", string(liveMsg.Data))
416- }
417- if !strings.Contains(string(liveMsg.Data), "live") {
418- t.Fatalf("live repaint = %q, want live screen", string(liveMsg.Data))
419- }
420-
421- d.paint(sess, ac, true)
422- followup := awaitFrame(t, sends, ports.MsgOutput)
423- followupMsg, err := ports.UnmarshalOutput(followup.Payload)
424- require.NoError(t, err)
425- if strings.Contains(string(followupMsg.Data), "copied 9 chars to clipboard") {
426- t.Fatalf("copy feedback persisted after next repaint: %q", string(followupMsg.Data))
427- }
428-}
429-
430-func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
431- p := portsmocks.NewMockPTY(t)
432- reads := make(chan []byte, 64)
433- readDone := make(chan struct{})
434- var closeOnce sync.Once
435- p.EXPECT().Read(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
436- select {
437- case data := <-reads:
438- return copy(b, data), nil
439- case <-readDone:
440- return 0, io.EOF
441- }
442- }).Maybe()
443- p.EXPECT().Write(mock.Anything).Return(0, errors.New("unexpected PTY write")).Maybe()
444- p.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
445- p.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(readDone) }); return nil }).Maybe()
446- p.EXPECT().Pid().Return(4242).Maybe()
447-
448- d := newTestDaemon(t, newFactory(t, p), stubClock{})
449- tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 16, Rows: 5}))
450- var hg sync.WaitGroup
451- hg.Go(func() { d.handleConn(tr) })
452- awaitFrame(t, sends, ports.MsgWelcome)
453- awaitFrame(t, sends, ports.MsgOutput)
454-
455- for i := range 12 {
456- reads <- fmt.Appendf(nil, "line-%02d\r\n", i)
457- }
458- require.Eventually(t, func() bool {
459- sess := firstSession(d)
460- if sess == nil {
461- return false
462- }
463- win := sess.activeTab()
464- if win == nil {
465- return false
466- }
467- win.mu.Lock()
468- defer win.mu.Unlock()
469- return len(scopy.NewSnapshot(win.scrollback, win.screen.Frame).Rows) >= 12
470- }, 2*time.Second, 5*time.Millisecond)
471-
472- sess := firstSession(d)
473- require.NotNil(t, sess)
474- ac := sess.client
475- require.NotNil(t, ac)
476- d.handleInput(sess, ac, []byte("\x1bu"))
477- awaitFrame(t, sends, ports.MsgOutput)
478- d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'})
479-
480- var payload string
481- require.Eventually(t, func() bool {
482- out := awaitFrame(t, sends, ports.MsgOutput)
483- msg, err := ports.UnmarshalOutput(out.Payload)
484- require.NoError(t, err)
485- payload = string(msg.Data)
486- return strings.HasPrefix(payload, "\x1b]52;c;")
487- }, 2*time.Second, 5*time.Millisecond)
488- require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload)
489- require.True(t, strings.HasSuffix(payload, "\a"), "OSC52 payload = %q", payload)
490- decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a"))
491- require.NoError(t, err)
492- require.True(t, strings.HasPrefix(string(decoded), "line-00"), "decoded yank = %q", string(decoded))
493-
494- releaseConn()
495- closeOnce.Do(func() { close(readDone) })
496- hg.Wait()
497- d.sessWg.Wait()
498-}
499-
500-func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
501- p, _ := newBlockingPTY(t)
502- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
503- sess.tabs[0].scrollback = scopy.NewScrollback(4)
504- sess.tabs[0].screen.Write([]byte("live"))
505-
506- d.enterCopyMode(sess, ac)
507- awaitFrame(t, sends, ports.MsgOutput)
508- d.handleInput(sess, ac, []byte("q"))
509-
510- if ac.copyMode != nil {
511- t.Fatal("copy mode still active after q")
512- }
513- out := awaitFrame(t, sends, ports.MsgOutput)
514- msg, err := ports.UnmarshalOutput(out.Payload)
515- require.NoError(t, err)
516- if strings.Contains(string(msg.Data), "[COPY]") || !strings.Contains(string(msg.Data), "live") {
517- t.Fatalf("exit repaint = %q, want live full repaint without copy status", string(msg.Data))
518- }
519-}
520-
521-func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
522- cases := []struct {
523- name string
524- input [][]byte
525- wantCursor int
526- }{
527- {name: "escape then up arrow", input: [][]byte{[]byte("\x1b"), []byte("[A")}, wantCursor: 23},
528- {name: "escape then down arrow", input: [][]byte{[]byte("g"), []byte("\x1b"), []byte("[B")}, wantCursor: 1},
529- {name: "split up arrow", input: [][]byte{[]byte("\x1b["), []byte("A")}, wantCursor: 23},
530- {name: "split page down", input: [][]byte{[]byte("g"), []byte("\x1b[6"), []byte("~")}, wantCursor: 23},
531- }
532- for _, tc := range cases {
533- t.Run(tc.name, func(t *testing.T) {
534- p, _ := newBlockingPTY(t)
535- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
536- sess.tabs[0].scrollback = scopy.NewScrollback(4)
537- sess.tabs[0].scrollback.Append(testRow("old1 "))
538- sess.tabs[0].scrollback.Append(testRow("old2 "))
539- sess.tabs[0].screen.Write([]byte("live"))
540-
541- d.enterCopyMode(sess, ac)
542- awaitFrame(t, sends, ports.MsgOutput)
543- for _, input := range tc.input {
544- d.handleInput(sess, ac, input)
545- }
546-
547- require.NotNil(t, ac.copyMode)
548- require.Equal(t, tc.wantCursor, ac.copyMode.Cursor)
549- })
550- }
551-}
552-
553-func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
554- p, _ := newBlockingPTY(t)
555- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
556- sess.tabs[0].scrollback = scopy.NewScrollback(1)
557- longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)
558- sess.tabs[0].screen.Frame = renderer.NewFrame(len(longLine), 1)
559- copy(sess.tabs[0].screen.Frame.Row(0), testRow(longLine))
560-
561- d.enterCopyMode(sess, ac)
562- awaitFrame(t, sends, ports.MsgOutput)
563- d.handleInput(sess, ac, []byte{' ', 'y'})
564-
565- out := awaitFrame(t, sends, ports.MsgOutput)
566- msg, err := ports.UnmarshalOutput(out.Payload)
567- require.NoError(t, err)
568- if strings.Contains(string(msg.Data), "\x1b]52;") {
569- t.Fatalf("oversized yank emitted OSC52: %q", string(msg.Data))
570- }
571- if !strings.Contains(string(msg.Data), "selection too large to copy") {
572- t.Fatalf("oversized yank repaint = %q, want too-large feedback", string(msg.Data))
573- }
574-}
575-
576-func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
577- p, _ := newBlockingPTY(t)
578- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
579- clk := &signalClock{timers: make(chan *signalTimer, 1)}
580- d.clock = clk
581- sess.tabs[0].scrollback = scopy.NewScrollback(4)
582- sess.tabs[0].screen.Write([]byte("live"))
583-
584- d.enterCopyMode(sess, ac)
585- awaitFrame(t, sends, ports.MsgOutput)
586- d.handleInput(sess, ac, []byte("\x1b"))
587- timer := <-clk.timers
588- ac.copyMu.Lock()
589- require.NotNil(t, ac.copyMode)
590- ac.copyMu.Unlock()
591- timer.ch <- time.Now()
592- require.Eventually(t, func() bool {
593- ac.copyMu.Lock()
594- defer ac.copyMu.Unlock()
595- return ac.copyMode == nil
596- }, time.Second, 5*time.Millisecond)
597-}
598-
599-func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
600- p, _ := newBlockingPTY(t)
601- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
602- clk := &signalClock{timers: make(chan *signalTimer, 1)}
603- d.clock = clk
604- sess.tabs[0].scrollback = scopy.NewScrollback(4)
605- sess.tabs[0].screen.Write([]byte("live"))
606-
607- d.enterCopyMode(sess, ac)
608- awaitFrame(t, sends, ports.MsgOutput)
609- d.handleInput(sess, ac, []byte("\x1b"))
610- timer := <-clk.timers
611- d.enterCopyMode(sess, ac)
612- awaitFrame(t, sends, ports.MsgOutput)
613-
614- timer.ch <- time.Now()
615- require.Never(t, func() bool {
616- ac.copyMu.Lock()
617- defer ac.copyMu.Unlock()
618- return ac.copyMode == nil
619- }, 50*time.Millisecond, 5*time.Millisecond)
620- ac.copyMu.Lock()
621- require.Nil(t, ac.copyESC.timer)
622- require.Nil(t, ac.copyESC.done)
623- require.Empty(t, ac.copyPending)
624- ac.copyMu.Unlock()
625-}
626-
627-func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
628- cases := []struct {
629- name string
630- input []byte
631- }{
632- {name: "yank", input: []byte("y")},
633- {name: "enter", input: []byte("\r")},
634- }
635- for _, tc := range cases {
636- t.Run(tc.name, func(t *testing.T) {
637- p, _ := newBlockingPTY(t)
638- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
639- sess.tabs[0].scrollback = scopy.NewScrollback(4)
640- sess.tabs[0].screen.Write([]byte("live"))
641-
642- d.enterCopyMode(sess, ac)
643- awaitFrame(t, sends, ports.MsgOutput)
644- d.handleInput(sess, ac, tc.input)
645-
646- if ac.copyMode != nil {
647- t.Fatal("copy mode still active after empty yank")
648- }
649- out := awaitFrame(t, sends, ports.MsgOutput)
650- msg, err := ports.UnmarshalOutput(out.Payload)
651- require.NoError(t, err)
652- if strings.Contains(string(msg.Data), "\x1b]52;") {
653- t.Fatalf("empty yank emitted OSC52 clipboard clear: %q", string(msg.Data))
654- }
655- if strings.Contains(string(msg.Data), "[COPY]") || !strings.Contains(string(msg.Data), "live") {
656- t.Fatalf("empty yank repaint = %q, want live full repaint", string(msg.Data))
657- }
658- })
659- }
660-}
661-
662-func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
663- p, _ := newBlockingPTY(t)
664- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
665- sess.tabs[0].scrollback = scopy.NewScrollback(4)
666- sess.tabs[0].screen.Write([]byte("live"))
667-
668- done := make(chan struct{})
669- var drain sync.WaitGroup
670- drain.Go(func() {
671- for {
672- select {
673- case <-sends:
674- case <-done:
675- return
676- }
677- }
678- })
679-
680- var wg sync.WaitGroup
681- for range 100 {
682- wg.Go(func() {
683- d.enterCopyMode(sess, ac)
684- d.handleInput(sess, ac, []byte("q"))
685- })
686- wg.Go(func() {
687- d.paint(sess, ac, true)
688- })
689- }
690- wg.Wait()
691- close(done)
692- drain.Wait()
693-}
694-
695283 func tabCount(sess *session) int {
696284 sess.mu.Lock()
697285 defer sess.mu.Unlock()
@@ -700,263 +288,6 @@ func tabCount(sess *session) int {
700288
701289 // --- handshake --------------------------------------------------------------
702290
703-func TestHandshakeEphemeralHappy(t *testing.T) {
704- p, releasePTY := newBlockingPTY(t)
705- d := newTestDaemon(t, newFactory(t, p), stubClock{})
706- tr, sends, releaseConn := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
707-
708- var hg sync.WaitGroup
709- hg.Go(func() { d.handleConn(tr) })
710-
711- w := awaitFrame(t, sends, ports.MsgWelcome)
712- welcome, err := ports.UnmarshalWelcome(w.Payload)
713- require.NoError(t, err)
714- require.Equal(t, "0", welcome.SessionName)
715- require.True(t, welcome.Ephemeral)
716- awaitFrame(t, sends, ports.MsgOutput) // guaranteed first paint
717- require.Equal(t, 1, sessionCount(d))
718-
719- releaseConn()
720- releasePTY()
721- hg.Wait()
722- d.sessWg.Wait()
723-}
724-
725-func TestHandshakeNewHappy(t *testing.T) {
726- p, releasePTY := newBlockingPTY(t)
727- d := newTestDaemon(t, newFactory(t, p), stubClock{})
728- tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
729-
730- var hg sync.WaitGroup
731- hg.Go(func() { d.handleConn(tr) })
732-
733- w := awaitFrame(t, sends, ports.MsgWelcome)
734- welcome, err := ports.UnmarshalWelcome(w.Payload)
735- require.NoError(t, err)
736- require.Equal(t, "work", welcome.SessionName)
737- require.False(t, welcome.Ephemeral)
738-
739- sess := firstSession(d)
740- require.NotNil(t, sess)
741-
742- releaseConn()
743- d.killSession(sess, ports.ReasonServerShutdown)
744- releasePTY()
745- hg.Wait()
746- d.sessWg.Wait()
747- d.waitNotifies()
748-}
749-
750-func TestHandshakeVersionMismatch(t *testing.T) {
751- // No Open expectation: the factory must never be asked to spawn a PTY.
752- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
753- bad := ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{
754- Version: ports.ProtocolVersion + 99, Intent: ports.IntentEphemeral, Size: domain.Size{Cols: 80, Rows: 24},
755- })}
756- tr, sends, _ := newConn(t, bad)
757-
758- d.handleConn(tr) // returns after the rejection; no session, no goroutines
759-
760- e := awaitFrame(t, sends, ports.MsgError)
761- em, err := ports.UnmarshalErrorMsg(e.Payload)
762- require.NoError(t, err)
763- require.Equal(t, ports.ErrVersionMismatch, em.Code)
764- require.Equal(t, 0, sessionCount(d))
765-}
766-
767-func TestHandshakeOldHelloLayoutReportsVersionMismatch(t *testing.T) {
768- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
769- oldLayout := []byte{
770- 0x00, byte(ports.ProtocolVersion - 1),
771- ports.IntentEphemeral,
772- 0x00, 0x00,
773- 0x00, 0x50,
774- 0x00, 0x18,
775- 0x00, 0x00,
776- }
777- tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: oldLayout})
778- d.handleConn(tr)
779-
780- e := awaitFrame(t, sends, ports.MsgError)
781- em, err := ports.UnmarshalErrorMsg(e.Payload)
782- require.NoError(t, err)
783- require.Equal(t, ports.ErrVersionMismatch, em.Code)
784- require.Equal(t, 0, sessionCount(d))
785-}
786-
787-func TestHandshakeNameTaken(t *testing.T) {
788- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
789- // Pre-populate a bare session (no goroutines) to collide with.
790- d.sessions[domain.SessionID("x")] = &session{id: "x", name: "work"}
791-
792- tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
793- d.handleConn(tr)
794-
795- e := awaitFrame(t, sends, ports.MsgError)
796- em, _ := ports.UnmarshalErrorMsg(e.Payload)
797- require.Equal(t, ports.ErrNameTaken, em.Code)
798-}
799-
800-func TestHandshakeNoSuchSession(t *testing.T) {
801- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
802- tr, sends, _ := newConn(t, mustHello(ports.IntentAttach, "ghost", domain.Size{Cols: 80, Rows: 24}))
803- d.handleConn(tr)
804-
805- e := awaitFrame(t, sends, ports.MsgError)
806- em, _ := ports.UnmarshalErrorMsg(e.Payload)
807- require.Equal(t, ports.ErrNoSuchSession, em.Code)
808-}
809-
810-func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) {
811- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
812- tr, _, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})})
813- d.handleConn(tr)
814-
815- select {
816- case <-d.done:
817- case <-time.After(time.Second):
818- t.Fatal("kill all on empty daemon did not signal shutdown")
819- }
820-}
821-
822-// --- ephemeral numbering ----------------------------------------------------
823-
824-func TestEphemeralNumberingReuse(t *testing.T) {
825- d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
826- // Emulate two ephemeral sessions "0" and "1".
827- d.sessions["a"] = &session{id: "a", name: "0"}
828- d.sessions["b"] = &session{id: "b", name: "1"}
829- require.Equal(t, "2", d.allocEphemeralNameLocked())
830-
831- // Kill "0": the freed number is reused before "2".
832- delete(d.sessions, "a")
833- require.Equal(t, "0", d.allocEphemeralNameLocked())
834-}
835-
836-// --- attach replace ---------------------------------------------------------
837-
838-func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
839- writes1 := make(chan []byte, 1)
840- writes2 := make(chan []byte, 1)
841- writes3 := make(chan []byte, 1)
842- p1, releasePTY1 := newBlockingPTYWithWrites(t, writes1)
843- p2, releasePTY2 := newBlockingPTYWithWrites(t, writes2)
844- p3, releasePTY3 := newBlockingPTYWithWrites(t, writes3)
845- d := newTestDaemon(t, newFactorySeq(t, p1, p2, p3), stubClock{})
846- tr, sends, releaseConn := newConn(t,
847- mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}),
848- frameInput([]byte("\x1bc")),
849- frameInput([]byte("\x1bc")),
850- frameInput([]byte("\x1b1")),
851- frameInput([]byte("A")),
852- frameInput([]byte("\x1b2")),
853- frameInput([]byte("B")),
854- frameInput([]byte("\x1b3")),
855- frameInput([]byte("C")),
856- )
857-
858- var hg sync.WaitGroup
859- hg.Go(func() { d.handleConn(tr) })
860- awaitFrame(t, sends, ports.MsgWelcome)
861- awaitFrame(t, sends, ports.MsgOutput)
862-
863- require.Eventually(t, func() bool {
864- sessions := listSessions(t, d)
865- return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 3
866- }, 2*time.Second, 5*time.Millisecond)
867- require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond)
868- require.Equal(t, []byte("A"), <-writes1)
869- require.Eventually(t, func() bool { return len(writes2) == 1 }, 2*time.Second, 5*time.Millisecond)
870- require.Equal(t, []byte("B"), <-writes2)
871- require.Eventually(t, func() bool { return len(writes3) == 1 }, 2*time.Second, 5*time.Millisecond)
872- require.Equal(t, []byte("C"), <-writes3)
873-
874- releaseConn()
875- releasePTY1()
876- releasePTY2()
877- releasePTY3()
878- hg.Wait()
879- d.sessWg.Wait()
880-}
881-
882-func TestAltCCreatesSecondActiveTab(t *testing.T) {
883- p1, releasePTY1 := newBlockingPTY(t)
884- p2, releasePTY2 := newBlockingPTY(t)
885- d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
886- tr, sends, releaseConn := newConn(t,
887- mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}),
888- frameInput([]byte("\x1bc")),
889- )
890-
891- var hg sync.WaitGroup
892- hg.Go(func() { d.handleConn(tr) })
893- awaitFrame(t, sends, ports.MsgWelcome)
894- awaitFrame(t, sends, ports.MsgOutput)
895-
896- require.Eventually(t, func() bool {
897- sessions := listSessions(t, d)
898- return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 2
899- }, 2*time.Second, 5*time.Millisecond)
900-
901- releaseConn()
902- releasePTY1()
903- releasePTY2()
904- hg.Wait()
905- d.sessWg.Wait()
906-}
907-
908-func TestAltNextPreviousSwitchActiveTab(t *testing.T) {
909- cases := []struct {
910- name string
911- start int
912- input []byte
913- wantIndex int
914- }{
915- {name: "next advances", start: 0, input: []byte("\x1bn"), wantIndex: 1},
916- {name: "next wraps", start: 2, input: []byte("\x1bn"), wantIndex: 0},
917- {name: "previous moves back", start: 2, input: []byte("\x1bp"), wantIndex: 1},
918- {name: "previous wraps", start: 0, input: []byte("\x1bp"), wantIndex: 2},
919- }
920-
921- for _, tc := range cases {
922- t.Run(tc.name, func(t *testing.T) {
923- d, sess, ac, _, releases := newManualTabSession(t, 3)
924- defer func() {
925- for _, release := range releases {
926- release()
927- }
928- }()
929- sess.active = tc.start
930-
931- d.handleInput(sess, ac, tc.input)
932-
933- require.Equal(t, tc.wantIndex, activeTabIndex(sess))
934- })
935- }
936-}
937-
938-func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {
939- writes := make(chan []byte, 1)
940- p1, releasePTY1 := newBlockingPTY(t)
941- p2, releasePTY2 := newBlockingPTY(t)
942- p3, releasePTY3 := newBlockingPTYWithWrites(t, writes)
943- d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2, p3)
944- sess.active = 1
945-
946- d.handleInput(sess, ac, []byte("\x1bx"))
947-
948- require.Equal(t, 1, sessionCount(d))
949- require.Len(t, sess.tabs, 2)
950- require.Equal(t, 1, activeTabIndex(sess), "closing middle tab selects the next remaining tab")
951- d.handleInput(sess, ac, []byte("Z"))
952- require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond)
953- require.Equal(t, []byte("Z"), <-writes)
954-
955- releasePTY1()
956- releasePTY2()
957- releasePTY3()
958-}
959-
960291 func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} {
961292 done := make(chan struct{})
962293 go func() {
@@ -966,1411 +297,39 @@ func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} {
966297 return done
967298 }
968299
969-func TestAltXClosesNonFinalTabScheduler(t *testing.T) {
970- p1, releasePTY1 := newBlockingPTY(t)
971- p2, releasePTY2 := newBlockingPTY(t)
972- clk := &signalClock{called: make(chan struct{})}
973- d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
974- d.clock = clk
975- defer releasePTY1()
976- defer releasePTY2()
977-
978- d.sessWg.Add(1)
979- go d.scheduler(sess, sess.tabs[1])
980- sess.tabs[1].dirty <- struct{}{}
981- <-clk.called
982-
983- d.handleInput(sess, ac, []byte("\x1b2"))
984- d.handleInput(sess, ac, []byte("\x1bx"))
985-
986- select {
987- case <-waitGroupDone(&d.sessWg):
988- case <-time.After(2 * time.Second):
989- t.Fatal("scheduler for removed tab did not exit")
300+func rowText(row []renderer.Cell) string {
301+ runes := make([]rune, len(row))
302+ for i, c := range row {
303+ runes[i] = c.Rune
990304 }
991- require.Equal(t, 1, sessionCount(d))
305+ return string(runes)
992306 }
993307
994-func TestPTYEOFClosesNonFinalTabScheduler(t *testing.T) {
995- p1, releasePTY1 := newBlockingPTY(t)
996- p2, releasePTY2 := newBlockingPTY(t)
997- clk := &signalClock{called: make(chan struct{})}
998- d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
999- d.clock = clk
1000- defer releasePTY1()
1001- defer releasePTY2()
1002-
1003- d.sessWg.Add(2)
1004- go d.scheduler(sess, sess.tabs[1])
1005- go d.ptyReader(sess, sess.tabs[1])
1006- sess.tabs[1].dirty <- struct{}{}
1007- <-clk.called
1008- releasePTY2()
308+type notifyClock struct{}
1009309
1010- select {
1011- case <-waitGroupDone(&d.sessWg):
1012- case <-time.After(2 * time.Second):
1013- t.Fatal("scheduler for EOF-removed tab did not exit")
310+func (notifyClock) Now() time.Time { return time.Now() }
311+func (notifyClock) NewTimer(d time.Duration) ports.Timer {
312+ if d == debounceInterval {
313+ return stubTimer{}
1014314 }
1015- require.Equal(t, 1, sessionCount(d))
1016-}
1017-
1018-func TestAltXClosesFinalTabAndDetaches(t *testing.T) {
1019- d, sess, ac, sends, releases := newManualTabSession(t, 1)
1020- defer releases[0]()
1021-
1022- d.handleInput(sess, ac, []byte("\x1bx"))
1023-
1024- require.Equal(t, 0, sessionCount(d))
1025- f := awaitFrame(t, sends, ports.MsgDetached)
1026- det, err := ports.UnmarshalDetached(f.Payload)
1027- require.NoError(t, err)
1028- require.Equal(t, ports.ReasonSessionKilled, det.Reason)
315+ return realTimer{t: time.NewTimer(5 * time.Millisecond)}
1029316 }
1030317
1031-func TestPTYEOFClosesActiveNonFinalTabAndRepaintsRemaining(t *testing.T) {
1032- p1, releasePTY1 := newBlockingPTY(t)
1033- p2, releasePTY2 := newBlockingPTY(t)
1034- d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
1035- defer releasePTY2()
1036- sess.active = 0
1037- sess.tabs[1].screen.Write([]byte("remaining"))
1038-
1039- d.sessWg.Add(1)
1040- go d.ptyReader(sess, sess.tabs[0])
1041- releasePTY1()
1042-
1043- require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
1044- require.Equal(t, 1, sessionCount(d))
1045- require.Equal(t, 0, activeTabIndex(sess))
1046- f := awaitFrame(t, sends, ports.MsgOutput)
1047- out, err := ports.UnmarshalOutput(f.Payload)
1048- require.NoError(t, err)
1049- data := string(out.Data)
1050- require.Contains(t, data, "remaining")
1051- require.Contains(t, data, "work")
1052- require.Contains(t, data, ";7m")
1053-
1054- d.sessWg.Wait()
1055-}
318+type realTimer struct{ t *time.Timer }
1056319
1057-func TestPTYEOFClosesInactiveNonFinalTabAndRepaintsStatus(t *testing.T) {
1058- p1, releasePTY1 := newBlockingPTY(t)
1059- p2, releasePTY2 := newBlockingPTY(t)
1060- d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
1061- defer releasePTY1()
1062- sess.active = 0
1063- sess.tabs[0].screen.Write([]byte("active"))
320+func (r realTimer) C() <-chan time.Time { return r.t.C }
321+func (r realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }
322+func (r realTimer) Stop() bool { return r.t.Stop() }
1064323
1065- d.sessWg.Add(1)
1066- go d.ptyReader(sess, sess.tabs[1])
1067- releasePTY2()
324+// TestServeReturnsDespiteWedgedClientOnShutdown: killSession's teardown must
325+// not be gated behind the Detached notice. The client transport wedges every
326+// MsgDetached send until the transport is closed (mirroring a full kernel
327+// send buffer: only Close fails the in-flight write). Serve must still tear
1068328
1069- require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
1070- require.Equal(t, 1, sessionCount(d))
1071- require.Equal(t, 0, activeTabIndex(sess))
1072- f := awaitFrame(t, sends, ports.MsgOutput)
1073- out, err := ports.UnmarshalOutput(f.Payload)
1074- require.NoError(t, err)
1075- data := string(out.Data)
1076- require.Contains(t, data, "active")
1077- require.Contains(t, data, "work")
1078- require.NotContains(t, data, " 2 ")
1079-
1080- d.sessWg.Wait()
1081-}
1082-
1083-func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) {
1084- d, sess, _, sends, releases := newManualTabSession(t, 1)
1085-
1086- d.sessWg.Add(1)
1087- go d.ptyReader(sess, sess.tabs[0])
1088- releases[0]()
1089-
1090- require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond)
1091- f := awaitFrame(t, sends, ports.MsgDetached)
1092- det, err := ports.UnmarshalDetached(f.Payload)
1093- require.NoError(t, err)
1094- require.Equal(t, ports.ReasonSessionKilled, det.Reason)
1095-
1096- d.sessWg.Wait()
1097-}
1098-
1099-func TestAltDDetachesCurrentClient(t *testing.T) {
1100- d, sess, ac, sends, releases := newManualTabSession(t, 1)
1101- defer releases[0]()
1102-
1103- d.handleInput(sess, ac, []byte("\x1bd"))
1104-
1105- require.Nil(t, sess.client)
1106- f := awaitFrame(t, sends, ports.MsgDetached)
1107- det, err := ports.UnmarshalDetached(f.Payload)
1108- require.NoError(t, err)
1109- require.Equal(t, ports.ReasonDetach, det.Reason)
1110-}
1111-
1112-func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) {
1113- d, sess, ac, sends, releases := newManualTabSession(t, 1)
1114- defer releases[0]()
1115- sess.ephemeral = true
1116- sess.name = "0"
1117-
1118- d.handleInput(sess, ac, []byte("\x1br"))
1119-
1120- require.False(t, sess.ephemeral)
1121- require.Equal(t, "0", sess.name)
1122- awaitFrame(t, sends, ports.MsgOutput)
1123-}
1124-
1125-func TestAltTOpensPickerModalAndDoesNotForward(t *testing.T) {
1126- writes := make(chan []byte, 1)
1127- p, releasePTY := newBlockingPTYWithWrites(t, writes)
1128- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1129- defer releasePTY()
1130-
1131- d.handleInput(sess, ac, []byte("\x1bt"))
1132-
1133- require.True(t, ac.pickerActive())
1134- select {
1135- case got := <-writes:
1136- t.Fatalf("picker binding forwarded to PTY: %q", got)
1137- default:
1138- }
1139- out := awaitFrame(t, sends, ports.MsgOutput)
1140- msg, err := ports.UnmarshalOutput(out.Payload)
1141- require.NoError(t, err)
1142- data := string(msg.Data)
1143- require.Contains(t, data, "┌")
1144- require.Contains(t, data, "Sessions")
1145-}
1146-
1147-func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
1148- d, sess, ac, sends, releases := newManualTabSession(t, 2)
1149- defer func() {
1150- for _, release := range releases {
1151- release()
1152- }
1153- }()
1154-
1155- d.handleInput(sess, ac, []byte("\x1bt"))
1156- awaitFrame(t, sends, ports.MsgOutput)
1157- d.handleInput(sess, ac, []byte("j"))
1158- awaitFrame(t, sends, ports.MsgOutput)
1159- d.handleInput(sess, ac, []byte("\r"))
1160-
1161- require.Equal(t, 1, activeTabIndex(sess))
1162- awaitFrame(t, sends, ports.MsgOutput)
1163- d.handleInput(sess, ac, []byte("\x1bt"))
1164- awaitFrame(t, sends, ports.MsgOutput)
1165- clk := &signalClock{timers: make(chan *signalTimer, 1)}
1166- d.clock = clk
1167- d.handleInput(sess, ac, []byte("\x1b"))
1168- timer := <-clk.timers
1169- require.True(t, ac.pickerActive())
1170- timer.ch <- time.Now()
1171- require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond)
1172- awaitFrame(t, sends, ports.MsgOutput)
1173-}
1174-
1175-func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
1176- cases := []struct {
1177- name string
1178- input [][]byte
1179- wantActive int
1180- }{
1181- {name: "escape then down arrow", input: [][]byte{[]byte("\x1b"), []byte("[B")}, wantActive: 1},
1182- {name: "escape then up arrow", input: [][]byte{[]byte("j"), []byte("\x1b"), []byte("[A")}, wantActive: 0},
1183- {name: "split down arrow", input: [][]byte{[]byte("\x1b["), []byte("B")}, wantActive: 1},
1184- }
1185- for _, tc := range cases {
1186- t.Run(tc.name, func(t *testing.T) {
1187- d, sess, ac, sends, releases := newManualTabSession(t, 2)
1188- defer func() {
1189- for _, release := range releases {
1190- release()
1191- }
1192- }()
1193-
1194- d.handleInput(sess, ac, []byte("\x1bt"))
1195- awaitFrame(t, sends, ports.MsgOutput)
1196- for _, input := range tc.input {
1197- d.handleInput(sess, ac, input)
1198- }
1199- require.True(t, ac.pickerActive())
1200- d.handleInput(sess, ac, []byte("\r"))
1201- require.Equal(t, tc.wantActive, activeTabIndex(sess))
1202- })
1203- }
1204-}
1205-
1206-func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
1207- d, sess, ac, sends, releases := newManualTabSession(t, 1)
1208- defer releases[0]()
1209-
1210- d.handleInput(sess, ac, []byte("\x1bt"))
1211- awaitFrame(t, sends, ports.MsgOutput)
1212- clk := &signalClock{timers: make(chan *signalTimer, 1)}
1213- d.clock = clk
1214- d.handleInput(sess, ac, []byte("\x1b"))
1215- timer := <-clk.timers
1216- require.True(t, ac.pickerActive())
1217- timer.ch <- time.Now()
1218- require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond)
1219-}
1220-
1221-func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
1222- p1, releasePTY1 := newBlockingPTY(t)
1223- p2, releasePTY2 := newBlockingPTY(t)
1224- defer releasePTY1()
1225- defer releasePTY2()
1226- d := newTestDaemon(t, nil, stubClock{})
1227- tr1, sends1 := newCapturingTransport(t)
1228- tr2, sends2 := newCapturingTransport(t)
1229- ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
1230- ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
1231- sctx1, cancel1 := context.WithCancel(d.serveCtx)
1232- sctx2, cancel2 := context.WithCancel(d.serveCtx)
1233- defer cancel1()
1234- defer cancel2()
1235- 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}
1236- 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}
1237- ac1.setSession(sess1)
1238- ac2.setSession(sess2)
1239- ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
1240- ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2})
1241- d.sessions[sess1.id] = sess1
1242- d.sessions[sess2.id] = sess2
1243-
1244- d.handleInput(sess1, ac1, []byte("\x1bt"))
1245- awaitFrame(t, sends1, ports.MsgOutput)
1246- d.handleInput(sess1, ac1, []byte("j"))
1247- awaitFrame(t, sends1, ports.MsgOutput)
1248- d.handleInput(sess1, ac1, []byte("\r"))
1249-
1250- require.Same(t, sess2, ac1.currentSession())
1251- require.Same(t, ac1, sess2.client)
1252- require.Nil(t, sess1.client)
1253- require.Equal(t, 2, sessionCount(d), "old ephemeral session remains alive after picker switch")
1254- det := awaitFrame(t, sends2, ports.MsgDetached)
1255- dm, err := ports.UnmarshalDetached(det.Payload)
1256- require.NoError(t, err)
1257- require.Equal(t, ports.ReasonDetach, dm.Reason)
1258- awaitFrame(t, sends1, ports.MsgOutput)
1259-}
1260-
1261-func TestPickerLivePreviewRepaintsInactiveTab(t *testing.T) {
1262- d, sess, ac, sends, releases := newManualTabSession(t, 2)
1263- defer func() {
1264- for _, release := range releases {
1265- release()
1266- }
1267- }()
1268-
1269- d.handleInput(sess, ac, []byte("\x1bt"))
1270- awaitFrame(t, sends, ports.MsgOutput)
1271- d.handleInput(sess, ac, []byte("j"))
1272- awaitFrame(t, sends, ports.MsgOutput)
1273-
1274- previewTab := sess.tabs[1]
1275- previewTab.mu.Lock()
1276- previewTab.screen.Write([]byte("inactive-preview-live"))
1277- previewTab.mu.Unlock()
1278- d.render(sess, previewTab)
1279-
1280- previewOut := awaitFrame(t, sends, ports.MsgOutput)
1281- previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
1282- require.NoError(t, err)
1283- require.Contains(t, string(previewMsg.Data), "inactive-preview-live")
1284-}
1285-
1286-func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
1287- p1, releasePTY1 := newBlockingPTY(t)
1288- p2, releasePTY2 := newBlockingPTY(t)
1289- defer releasePTY1()
1290- defer releasePTY2()
1291- d := newTestDaemon(t, nil, stubClock{})
1292- tr1, sends1 := newCapturingTransport(t)
1293- tr2, _ := newCapturingTransport(t)
1294- ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
1295- ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
1296- sctx1, cancel1 := context.WithCancel(d.serveCtx)
1297- sctx2, cancel2 := context.WithCancel(d.serveCtx)
1298- defer cancel1()
1299- defer cancel2()
1300- 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}
1301- 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}
1302- ac1.setSession(sess1)
1303- ac2.setSession(sess2)
1304- ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
1305- ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2})
1306- d.sessions[sess1.id] = sess1
1307- d.sessions[sess2.id] = sess2
1308-
1309- d.handleInput(sess1, ac1, []byte("\x1bt"))
1310- awaitFrame(t, sends1, ports.MsgOutput)
1311- d.handleInput(sess1, ac1, []byte("j"))
1312- awaitFrame(t, sends1, ports.MsgOutput)
1313-
1314- previewTab := sess2.tabs[0]
1315- previewTab.mu.Lock()
1316- previewTab.screen.Write([]byte("cross-session-preview-live"))
1317- previewTab.mu.Unlock()
1318- d.render(sess2, previewTab)
1319-
1320- previewOut := awaitFrame(t, sends1, ports.MsgOutput)
1321- previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
1322- require.NoError(t, err)
1323- require.Contains(t, string(previewMsg.Data), "cross-session-preview-live")
1324-}
1325-
1326-func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) {
1327- d, sess, ac, sends, releases := newManualTabSession(t, 3)
1328- defer func() {
1329- for _, release := range releases {
1330- release()
1331- }
1332- }()
1333-
1334- done := make(chan struct{})
1335- var drain sync.WaitGroup
1336- drain.Go(func() {
1337- for {
1338- select {
1339- case <-sends:
1340- case <-done:
1341- return
1342- }
1343- }
1344- })
1345-
1346- var wg sync.WaitGroup
1347- for i := range 100 {
1348- wg.Go(func() {
1349- d.enterPicker(sess, ac)
1350- })
1351- wg.Go(func() {
1352- d.handlePickerInput(ac, []byte("j"))
1353- d.handlePickerInput(ac, []byte("k"))
1354- })
1355- wg.Go(func() {
1356- d.closePicker(ac)
1357- })
1358- tb := sess.tabs[i%len(sess.tabs)]
1359- wg.Go(func() {
1360- tb.mu.Lock()
1361- tb.screen.Write([]byte("render-race"))
1362- tb.mu.Unlock()
1363- d.render(sess, tb)
1364- })
1365- }
1366- wg.Wait()
1367- close(done)
1368- drain.Wait()
1369-}
1370-
1371-func TestPickerResizeRecomposesModal(t *testing.T) {
1372- p, releasePTY := newBlockingPTY(t)
1373- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1374- defer releasePTY()
1375-
1376- d.handleInput(sess, ac, []byte("\x1bt"))
1377- awaitFrame(t, sends, ports.MsgOutput)
1378- d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
1379-
1380- out := awaitFrame(t, sends, ports.MsgOutput)
1381- msg, err := ports.UnmarshalOutput(out.Payload)
1382- require.NoError(t, err)
1383- require.Contains(t, string(msg.Data), "┌")
1384- require.Contains(t, string(msg.Data), "Sessions")
1385-}
1386-
1387-func TestStatusCompositionGolden(t *testing.T) {
1388- p1, releasePTY1 := newBlockingPTY(t)
1389- p2, releasePTY2 := newBlockingPTY(t)
1390- _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
1391- defer releasePTY1()
1392- defer releasePTY2()
1393- sess.active = 1
1394- sess.name = "work"
1395-
1396- win := sess.activeTab()
1397- win.screen = vt.NewScreen(12, 2)
1398- win.size = domain.Size{Cols: 12, Rows: 2}
1399- win.screen.Write([]byte("hello"))
1400-
1401- frame, damage := composeClientFrame(sess, win, true, "")
1402-
1403- require.Equal(t, 12, frame.Width)
1404- require.Equal(t, 3, frame.Height)
1405- require.Equal(t, "hello ", rowText(frame.Row(0)))
1406- require.Equal(t, " work 1 2 ", rowText(frame.Row(2)))
1407- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
1408- for i, c := range frame.Row(2) {
1409- if i >= len(" work 1 ") && i < len(" work 1 2 ") {
1410- require.True(t, c.Style.Inverse, "active tab segment cell %d should be inverse", i)
1411- }
1412- }
1413-}
1414-
1415-func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
1416- p, releasePTY := newBlockingPTY(t)
1417- _, sess, _, _ := newManualSessionWithPTYs(t, p)
1418- defer releasePTY()
1419- sess.name = "work"
1420- win := sess.activeTab()
1421- win.screen = vt.NewScreen(30, 2)
1422- win.size = domain.Size{Cols: 30, Rows: 2}
1423-
1424- frame, _ := composeClientFrame(sess, win, true, "ok")
1425- require.Equal(t, " work 1 ok", rowText(frame.Row(2)))
1426-
1427- frame, _ = composeClientFrame(sess, win, true, "1234567890123456789")
1428- require.Equal(t, " work 1 1234567890123456789", rowText(frame.Row(2)))
1429-
1430- frame, _ = composeClientFrame(sess, win, true, "selection too large to copy")
1431- require.Equal(t, " work 1 ", rowText(frame.Row(2)))
1432-}
1433-
1434-func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
1435- p1, releasePTY1 := newBlockingPTY(t)
1436- p2, releasePTY2 := newBlockingPTY(t)
1437- d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
1438- tr, sends, releaseConn := newConn(t,
1439- mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}),
1440- frameInput([]byte("\x1bc")),
1441- frameInput([]byte("\x1b1")),
1442- ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 22, Rows: 6}})},
1443- )
1444-
1445- var hg sync.WaitGroup
1446- hg.Go(func() { d.handleConn(tr) })
1447- awaitFrame(t, sends, ports.MsgWelcome)
1448- first := awaitFrame(t, sends, ports.MsgOutput)
1449- created := awaitFrame(t, sends, ports.MsgOutput)
1450- switched := awaitFrame(t, sends, ports.MsgOutput)
1451- resized := awaitFrame(t, sends, ports.MsgOutput)
1452-
1453- for _, f := range []ports.Frame{first, created, switched, resized} {
1454- out, err := ports.UnmarshalOutput(f.Payload)
1455- require.NoError(t, err)
1456- require.Contains(t, string(out.Data), "work")
1457- require.Contains(t, string(out.Data), ";7m", "active status tab should be inverse-highlighted")
1458- }
1459-
1460- releaseConn()
1461- releasePTY1()
1462- releasePTY2()
1463- hg.Wait()
1464- d.sessWg.Wait()
1465-}
1466-
1467-func rowText(row []renderer.Cell) string {
1468- runes := make([]rune, len(row))
1469- for i, c := range row {
1470- runes[i] = c.Rune
1471- }
1472- return string(runes)
1473-}
1474-
1475-func TestAttachReplaceDetachesOld(t *testing.T) {
1476- p, releasePTY := newBlockingPTY(t)
1477- d := newTestDaemon(t, newFactory(t, p), stubClock{})
1478-
1479- // Client A creates and attaches to ephemeral "0".
1480- trA, sendsA, releaseA := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
1481- var hg sync.WaitGroup
1482- hg.Go(func() { d.handleConn(trA) })
1483- awaitFrame(t, sendsA, ports.MsgWelcome)
1484- awaitFrame(t, sendsA, ports.MsgOutput)
1485- sess := firstSession(d)
1486- require.NotNil(t, sess)
1487-
1488- // Client B attaches to the same session, displacing A.
1489- trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24}))
1490- hg.Go(func() { d.handleConn(trB) })
1491- awaitFrame(t, sendsB, ports.MsgWelcome)
1492-
1493- // A is notified it was detached.
1494- dA := awaitFrame(t, sendsA, ports.MsgDetached)
1495- det, _ := ports.UnmarshalDetached(dA.Payload)
1496- require.Equal(t, ports.ReasonDetach, det.Reason)
1497-
1498- // B is now the current client.
1499- sess.mu.Lock()
1500- require.NotNil(t, sess.client)
1501- sess.mu.Unlock()
1502-
1503- releaseA()
1504- releaseB()
1505- d.killSession(sess, ports.ReasonServerShutdown)
1506- releasePTY()
1507- hg.Wait()
1508- d.sessWg.Wait()
1509- d.waitNotifies()
1510-}
1511-
1512-// --- detach semantics -------------------------------------------------------
1513-
1514-func TestDetachKillsEphemeral(t *testing.T) {
1515- p, releasePTY := newBlockingPTY(t)
1516- d := newTestDaemon(t, newFactory(t, p), stubClock{})
1517- tr, sends, _ := newConn(t,
1518- mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}),
1519- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})},
1520- )
1521-
1522- var hg sync.WaitGroup
1523- hg.Go(func() { d.handleConn(tr) })
1524- awaitFrame(t, sends, ports.MsgWelcome)
1525-
1526- require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond,
1527- "ephemeral session must die on client detach")
1528-
1529- releasePTY()
1530- hg.Wait()
1531- d.sessWg.Wait()
1532-}
1533-
1534-func TestDetachKeepsNamed(t *testing.T) {
1535- p, releasePTY := newBlockingPTY(t)
1536- d := newTestDaemon(t, newFactory(t, p), stubClock{})
1537- tr, sends, _ := newConn(t,
1538- mustHello(ports.IntentNew, "keep", domain.Size{Cols: 80, Rows: 24}),
1539- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})},
1540- )
1541-
1542- var hg sync.WaitGroup
1543- hg.Go(func() { d.handleConn(tr) })
1544- awaitFrame(t, sends, ports.MsgWelcome)
1545- awaitFrame(t, sends, ports.MsgDetached) // ack for explicit detach
1546- hg.Wait() // handler returns after detach
1547-
1548- require.Equal(t, 1, sessionCount(d), "named session survives detach")
1549- sess := firstSession(d)
1550- sess.mu.Lock()
1551- require.Nil(t, sess.client, "named session is headless after detach")
1552- sess.mu.Unlock()
1553-
1554- d.killSession(sess, ports.ReasonServerShutdown)
1555- releasePTY()
1556- d.sessWg.Wait()
1557- d.waitNotifies()
1558-}
1559-
1560-// --- render scheduler debounce ----------------------------------------------
1561-
1562-func TestSchedulerDebounceCoalesces(t *testing.T) {
1563- mc := portsmocks.NewMockClock(t)
1564- mc.EXPECT().Now().Return(time.Now()).Maybe()
1565- mt := portsmocks.NewMockTimer(t)
1566- timerCh := make(chan time.Time, 1)
1567- newTimerCalled := make(chan struct{}, 4)
1568-
1569- mc.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
1570- select {
1571- case newTimerCalled <- struct{}{}:
1572- default:
1573- }
1574- return mt
1575- }).Maybe()
1576- mt.EXPECT().C().Return(timerCh).Maybe()
1577- mt.EXPECT().Stop().Return(true).Maybe()
1578-
1579- var outputs atomic.Int32
1580- gotOutput := make(chan struct{}, 1)
1581- tr := portsmocks.NewMockTransport(t)
1582- tr.EXPECT().Close().Return(nil).Maybe()
1583- tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
1584- if f.Type == ports.MsgOutput {
1585- outputs.Add(1)
1586- select {
1587- case gotOutput <- struct{}{}:
1588- default:
1589- }
1590- }
1591- return nil
1592- }).Maybe()
1593-
1594- d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
1595- sctx, cancel := context.WithCancel(context.Background())
1596- win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
1597- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1598- sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1599- ac.setSession(sess)
1600-
1601- win.mu.Lock()
1602- win.screen.Write([]byte("hi"))
1603- win.mu.Unlock()
1604-
1605- d.sessWg.Add(1)
1606- go d.scheduler(sess, win)
1607-
1608- // First dirty opens the debounce window.
1609- win.dirty <- struct{}{}
1610- <-newTimerCalled
1611-
1612- // A burst of further dirties inside the window must be absorbed.
1613- for range 5 {
1614- select {
1615- case win.dirty <- struct{}{}:
1616- default:
1617- }
1618- }
1619-
1620- // Fire the timer once: exactly one render.
1621- timerCh <- time.Now()
1622- <-gotOutput
1623-
1624- cancel()
1625- d.sessWg.Wait()
1626- require.Equal(t, int32(1), outputs.Load(), "N dirties in one tab must render exactly once")
1627-}
1628-
1629-func TestSchedulerAdaptiveDebounceFloodAndIdle(t *testing.T) {
1630- delay := nextDebounceDelay(minDebounceInterval, 3)
1631- require.Greater(t, delay, minDebounceInterval, "sustained flood should widen debounce")
1632- delay = nextDebounceDelay(delay, 3)
1633- require.Greater(t, delay, minDebounceInterval+debounceStep, "continued flood should keep adapting")
1634-}
1635-
1636-func TestSchedulerAdaptiveDebounceResetsAfterQuietPeriod(t *testing.T) {
1637- delay := nextDebounceDelay(minDebounceInterval, 2)
1638- require.Greater(t, delay, minDebounceInterval)
1639- require.Equal(t, minDebounceInterval, nextDebounceDelay(delay, 0), "isolated update after quiet window should restore idle latency")
1640-}
1641-
1642-// --- resize ordering --------------------------------------------------------
1643-
1644-func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
1645- newSize := domain.Size{Cols: 100, Rows: 30}
1646-
1647- p := portsmocks.NewMockPTY(t)
1648- var screenWidthAtResize int
1649- win := &tab{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}}
1650- p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 29}).RunAndReturn(func(sz domain.Size) error {
1651- // The screen must not yet be resized when the PTY is: proves order.
1652- screenWidthAtResize = win.screen.Frame.Width
1653- return nil
1654- }).Once()
1655- win.pty = p
1656-
1657- var gotOutput atomic.Bool
1658- tr := portsmocks.NewMockTransport(t)
1659- tr.EXPECT().Close().Return(nil).Maybe()
1660- tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
1661- if f.Type == ports.MsgOutput {
1662- gotOutput.Store(true)
1663- }
1664- return nil
1665- }).Maybe()
1666-
1667- d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
1668- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1669- sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}
1670- ac.setSession(sess)
1671-
1672- d.resize(sess, ac, newSize)
1673-
1674- require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")
1675- require.Equal(t, 100, win.screen.Frame.Width, "screen resized after pty")
1676- require.Equal(t, 29, win.screen.Frame.Height, "screen reserves bottom status row")
1677- require.True(t, gotOutput.Load(), "resize forces a full redraw output")
1678-}
1679-
1680-// --- reader EOF -> registry-empty shutdown ----------------------------------
1681-
1682-func TestReaderEOFRemovesSessionAndSignalsShutdown(t *testing.T) {
1683- p := portsmocks.NewMockPTY(t)
1684- // Read returns EOF immediately (child already gone).
1685- p.EXPECT().Read(mock.Anything).Return(0, io.EOF).Maybe()
1686- p.EXPECT().Close().Return(nil).Maybe()
1687- p.EXPECT().Pid().Return(1).Maybe()
1688-
1689- d := newTestDaemon(t, newFactory(t, p), stubClock{})
1690- tr, sends, _ := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
1691-
1692- var hg sync.WaitGroup
1693- hg.Go(func() { d.handleConn(tr) })
1694-
1695- // The client is detached with ReasonSessionKilled when the child exits.
1696- det := awaitFrame(t, sends, ports.MsgDetached)
1697- dm, _ := ports.UnmarshalDetached(det.Payload)
1698- require.Equal(t, ports.ReasonSessionKilled, dm.Reason)
1699-
1700- select {
1701- case <-d.done:
1702- case <-time.After(2 * time.Second):
1703- t.Fatal("registry-empty shutdown was not signalled")
1704- }
1705- require.Equal(t, 0, sessionCount(d))
1706-
1707- hg.Wait()
1708- d.sessWg.Wait()
1709- d.waitNotifies()
1710-}
1711-
1712-// --- full Serve lifecycle ---------------------------------------------------
1713-
1714-func TestServeReturnsWhenLastSessionExits(t *testing.T) {
1715- p, releasePTY := newBlockingPTY(t)
1716- tr, sends, _ := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
1717-
1718- l := portsmocks.NewMockListener(t)
1719- connCh := make(chan ports.Transport, 1)
1720- connCh <- tr
1721- closed := make(chan struct{})
1722- var once sync.Once
1723- l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {
1724- select {
1725- case c := <-connCh:
1726- return c, nil
1727- case <-closed:
1728- return nil, io.EOF
1729- }
1730- }).Maybe()
1731- l.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil }).Maybe()
1732- l.EXPECT().Addr().Return("mock").Maybe()
1733-
1734- d := New(newFactory(t, p), stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
1735-
1736- served := make(chan error, 1)
1737- go func() { served <- d.Serve(context.Background(), l) }()
1738-
1739- awaitFrame(t, sends, ports.MsgWelcome)
1740- awaitFrame(t, sends, ports.MsgOutput)
1741-
1742- // Child exits -> session removed -> registry empties -> Serve returns.
1743- releasePTY()
1744-
1745- select {
1746- case err := <-served:
1747- require.NoError(t, err)
1748- case <-time.After(3 * time.Second):
1749- t.Fatal("Serve did not return after last session exited")
1750- }
1751-}
1752-
1753-func TestServeGracefulShutdownOnContextCancel(t *testing.T) {
1754- p, releasePTY := newBlockingPTY(t)
1755- defer releasePTY()
1756- tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "long", domain.Size{Cols: 80, Rows: 24}))
1757-
1758- l := portsmocks.NewMockListener(t)
1759- connCh := make(chan ports.Transport, 1)
1760- connCh <- tr
1761- closed := make(chan struct{})
1762- var once sync.Once
1763- l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {
1764- select {
1765- case c := <-connCh:
1766- return c, nil
1767- case <-closed:
1768- return nil, io.EOF
1769- }
1770- }).Maybe()
1771- l.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil }).Maybe()
1772- l.EXPECT().Addr().Return("mock").Maybe()
1773-
1774- d := New(newFactory(t, p), stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
1775- ctx, cancel := context.WithCancel(context.Background())
1776-
1777- served := make(chan error, 1)
1778- go func() { served <- d.Serve(ctx, l) }()
1779-
1780- awaitFrame(t, sends, ports.MsgWelcome)
1781- awaitFrame(t, sends, ports.MsgOutput)
1782-
1783- cancel() // graceful shutdown
1784-
1785- det := awaitFrame(t, sends, ports.MsgDetached)
1786- dm, _ := ports.UnmarshalDetached(det.Payload)
1787- require.Equal(t, ports.ReasonServerShutdown, dm.Reason)
1788-
1789- select {
1790- case err := <-served:
1791- require.NoError(t, err)
1792- case <-time.After(3 * time.Second):
1793- t.Fatal("Serve did not return after context cancel")
1794- }
1795-}
1796-
1797-// --- shutdown/create interlock ------------------------------------------------
1798-
1799-// TestHelloRacingShutdownIsRejected covers the interlock between the
1800-// registry-empty shutdown decision and session creation: once killSession has
1801-// removed the last session (making shutdown irreversible via doneOnce), a
1802-// Hello landing at any later instant must be rejected cleanly — never spawn a
1803-// child that no teardown pass would reap. The interleaving is deterministic:
1804-// killSession runs to completion before the racing Hellos are handled, which
1805-// models the worst case (insertion attempt after the shutdown snapshot).
1806-func TestHelloRacingShutdownIsRejected(t *testing.T) {
1807- p, releasePTY := newBlockingPTY(t)
1808- f := portsmocks.NewMockPTYFactory(t)
1809- // Exactly one child may ever be spawned; a second Open call (a leaked
1810- // child) fails the test via mock expectations.
1811- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Once()
1812- d := newTestDaemon(t, f, stubClock{})
1813-
1814- tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
1815- var hg sync.WaitGroup
1816- hg.Go(func() { d.handleConn(tr1) })
1817- awaitFrame(t, sends1, ports.MsgWelcome)
1818- sess := firstSession(d)
1819- require.NotNil(t, sess)
1820-
1821- // The last session dies: shutdown begins irreversibly.
1822- d.killSession(sess, ports.ReasonSessionKilled)
1823- select {
1824- case <-d.done:
1825- default:
1826- t.Fatal("registry-empty shutdown must be signalled synchronously by killSession")
1827- }
1828-
1829- // Racing Hellos — both creation intents must be rejected with a clean
1830- // typed error, and nothing may be inserted into the registry.
1831- for _, intent := range []struct {
1832- name string
1833- intent uint8
1834- sess string
1835- }{
1836- {"ephemeral", ports.IntentEphemeral, ""},
1837- {"named", ports.IntentNew, "latecomer"},
1838- } {
1839- tr2, sends2, _ := newConn(t, mustHello(intent.intent, intent.sess, domain.Size{Cols: 80, Rows: 24}))
1840- d.handleConn(tr2)
1841- e := awaitFrame(t, sends2, ports.MsgError)
1842- em, err := ports.UnmarshalErrorMsg(e.Payload)
1843- require.NoError(t, err)
1844- require.Equal(t, ports.ErrServerShutdown, em.Code, "%s hello racing shutdown must be rejected", intent.name)
1845- }
1846- require.Equal(t, 0, sessionCount(d), "no session may be inserted after shutdown began")
1847-
1848- release1()
1849- releasePTY()
1850- hg.Wait()
1851- d.sessWg.Wait()
1852- d.waitNotifies()
1853-}
1854-
1855-// --- wedged-client teardown ---------------------------------------------------
1856-
1857-// notifyClock drives the daemon with a stub debounce timer (schedulers park)
1858-// but a short real timer for the detach-notify deadline, so a wedged client's
1859-// transport is force-closed quickly and deterministically.
1860-type notifyClock struct{}
1861-
1862-func (notifyClock) Now() time.Time { return time.Now() }
1863-func (notifyClock) NewTimer(d time.Duration) ports.Timer {
1864- if d == debounceInterval {
1865- return stubTimer{}
1866- }
1867- return realTimer{t: time.NewTimer(5 * time.Millisecond)}
1868-}
1869-
1870-type realTimer struct{ t *time.Timer }
1871-
1872-func (r realTimer) C() <-chan time.Time { return r.t.C }
1873-func (r realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }
1874-func (r realTimer) Stop() bool { return r.t.Stop() }
1875-
1876-// TestServeReturnsDespiteWedgedClientOnShutdown: killSession's teardown must
1877-// not be gated behind the Detached notice. The client transport wedges every
1878-// MsgDetached send until the transport is closed (mirroring a full kernel
1879-// send buffer: only Close fails the in-flight write). Serve must still tear
1880-// down the PTY and return.
1881-func TestServeReturnsDespiteWedgedClientOnShutdown(t *testing.T) {
1882- p, _ := newBlockingPTY(t) // Close unblocks the parked reader
1883-
1884- tr := portsmocks.NewMockTransport(t)
1885- sends := make(chan ports.Frame, 64)
1886- recvCh := make(chan ports.Frame, 1)
1887- recvCh <- mustHello(ports.IntentNew, "wedge", domain.Size{Cols: 80, Rows: 24})
1888- connDone := make(chan struct{})
1889- var connOnce sync.Once
1890- closeConn := func() { connOnce.Do(func() { close(connDone) }) }
1891-
1892- tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
1893- select {
1894- case f := <-recvCh:
1895- return f, nil
1896- case <-connDone:
1897- return ports.Frame{}, io.EOF
1898- }
1899- }).Maybe()
1900- tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
1901- if f.Type == ports.MsgDetached {
1902- // Wedged: blocks until the transport is force-closed.
1903- <-connDone
1904- return io.ErrClosedPipe
1905- }
1906- sends <- f
1907- return nil
1908- }).Maybe()
1909- tr.EXPECT().Close().RunAndReturn(func() error { closeConn(); return nil }).Maybe()
1910-
1911- l := portsmocks.NewMockListener(t)
1912- connCh := make(chan ports.Transport, 1)
1913- connCh <- tr
1914- lnClosed := make(chan struct{})
1915- var lnOnce sync.Once
1916- l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {
1917- select {
1918- case c := <-connCh:
1919- return c, nil
1920- case <-lnClosed:
1921- return nil, io.EOF
1922- }
1923- }).Maybe()
1924- l.EXPECT().Close().RunAndReturn(func() error { lnOnce.Do(func() { close(lnClosed) }); return nil }).Maybe()
1925- l.EXPECT().Addr().Return("mock").Maybe()
1926-
1927- d := New(newFactory(t, p), notifyClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
1928- ctx, cancel := context.WithCancel(context.Background())
1929-
1930- served := make(chan error, 1)
1931- go func() { served <- d.Serve(ctx, l) }()
1932-
1933- awaitFrame(t, sends, ports.MsgWelcome)
1934- awaitFrame(t, sends, ports.MsgOutput)
1935-
1936- cancel() // shutdown with the wedged client still attached
1937-
1938- select {
1939- case err := <-served:
1940- require.NoError(t, err)
1941- case <-time.After(3 * time.Second):
1942- t.Fatal("Serve hung on a wedged client during teardown")
1943- }
1944- require.Equal(t, 0, sessionCount(d), "session must be torn down despite the wedged client")
1945-}
1946-
1947-// --- send-error kills ephemeral -----------------------------------------------
1948-
1949-// TestSendErrorKillsEphemeral: a failed output send detaches the client, and —
1950-// like every other detach path — that kills an ephemeral session rather than
1951-// leaving it headless-but-alive forever.
1952-func TestSendErrorKillsEphemeral(t *testing.T) {
1953- p := portsmocks.NewMockPTY(t)
1954- p.EXPECT().Close().Return(nil).Maybe()
1955-
1956- tr := portsmocks.NewMockTransport(t)
1957- tr.EXPECT().Send(mock.Anything).Return(io.ErrClosedPipe).Maybe()
1958- tr.EXPECT().Close().Return(nil).Maybe()
1959-
1960- d := newTestDaemon(t, nil, stubClock{})
1961- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
1962- sctx, cancel := context.WithCancel(context.Background())
1963- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1964- sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1965- ac.setSession(sess)
1966- d.sessions[sess.id] = sess
1967-
1968- win.mu.Lock()
1969- win.screen.Write([]byte("x"))
1970- win.mu.Unlock()
1971-
1972- d.paint(sess, ac, true) // send fails -> detach -> ephemeral killed
1973-
1974- require.Equal(t, 0, sessionCount(d), "ephemeral session must die when its client's send fails")
1975- d.waitNotifies()
1976-}
1977-
1978-func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T) {
1979- mc := portsmocks.NewMockClock(t)
1980- mc.EXPECT().Now().Return(time.Now()).Maybe()
1981- mt := portsmocks.NewMockTimer(t)
1982- timerCh := make(chan time.Time, 1)
1983- mc.EXPECT().NewTimer(mock.Anything).Return(mt).Maybe()
1984- mt.EXPECT().C().Return(timerCh).Maybe()
1985- mt.EXPECT().Stop().Return(true).Maybe()
1986-
1987- var outputs atomic.Int32
1988- gotOutput := make(chan struct{}, 1)
1989- tr := portsmocks.NewMockTransport(t)
1990- tr.EXPECT().Close().Return(nil).Maybe()
1991- tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
1992- if f.Type == ports.MsgOutput {
1993- outputs.Add(1)
1994- select {
1995- case gotOutput <- struct{}{}:
1996- default:
1997- }
1998- }
1999- return nil
2000- }).Maybe()
2001-
2002- d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
2003- sctx, cancel := context.WithCancel(context.Background())
2004- 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}
2005- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
2006- sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
2007- ac.setSession(sess)
2008-
2009- win.mu.Lock()
2010- win.screen.Write([]byte("before"))
2011- win.mu.Unlock()
2012-
2013- d.sessWg.Add(1)
2014- go d.scheduler(sess, win)
2015- win.dirty <- struct{}{}
2016-
2017- win.mu.Lock()
2018- win.screen.Write([]byte("\x1b[?2026hafter"))
2019- win.mu.Unlock()
2020- timerCh <- time.Now()
2021-
2022- select {
2023- case <-gotOutput:
2024- t.Fatal("scheduler rendered while synchronized update was active")
2025- case <-time.After(20 * time.Millisecond):
2026- }
2027- require.Equal(t, int32(0), outputs.Load())
2028-
2029- win.mu.Lock()
2030- win.screen.Write([]byte(" done\x1b[?2026l"))
2031- win.mu.Unlock()
2032- win.flush <- struct{}{}
2033- select {
2034- case <-gotOutput:
2035- case <-time.After(time.Second):
2036- t.Fatal("sync end did not flush deferred damage")
2037- }
2038-
2039- cancel()
2040- d.sessWg.Wait()
2041-}
2042-
2043-func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
2044- d := newTestDaemon(t, nil, stubClock{})
2045- p := portsmocks.NewMockPTY(t)
2046- chunks := [][]byte{[]byte("\x1b[6n")}
2047- writes := make(chan []byte, 1)
2048- p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
2049- if len(chunks) == 0 {
2050- return 0, io.EOF
2051- }
2052- n := copy(buf, chunks[0])
2053- chunks = chunks[1:]
2054- return n, nil
2055- })
2056- p.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
2057- writes <- append([]byte(nil), b...)
2058- return len(b), nil
2059- }).Once()
2060- p.EXPECT().Close().Return(nil).Maybe()
2061-
2062- tr, sends := newCapturingTransport(t)
2063- sctx, cancel := context.WithCancel(context.Background())
2064- 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}
2065- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
2066- sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
2067- ac.setSession(sess)
2068- d.sessions[sess.id] = sess
2069- d.sessWg.Add(1)
2070- d.ptyReader(sess, win)
2071-
2072- select {
2073- case got := <-writes:
2074- require.Equal(t, []byte("\x1b[1;1R"), got)
2075- case <-time.After(time.Second):
2076- t.Fatal("timed out waiting for PTY response write")
2077- }
2078- select {
2079- case f := <-sends:
2080- require.NotEqual(t, ports.MsgOutput, f.Type)
2081- default:
2082- }
2083-}
2084-
2085-func TestPTYReaderSameReadSynchronizedUpdateStartEndFlushesImmediately(t *testing.T) {
2086- d := newTestDaemon(t, nil, stubClock{})
2087- p := portsmocks.NewMockPTY(t)
2088- chunks := [][]byte{[]byte("\x1b[?2026hhello\x1b[?2026l")}
2089- p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
2090- if len(chunks) == 0 {
2091- return 0, io.EOF
2092- }
2093- n := copy(buf, chunks[0])
2094- chunks = chunks[1:]
2095- return n, nil
2096- })
2097- p.EXPECT().Close().Return(nil).Maybe()
2098-
2099- sctx, cancel := context.WithCancel(context.Background())
2100- 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}
2101- sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
2102- d.sessions[sess.id] = sess
2103- d.sessWg.Add(1)
2104- d.ptyReader(sess, win)
2105-
2106- select {
2107- case <-win.dirty:
2108- t.Fatal("same-read synchronized update end should request flush, not dirty")
2109- default:
2110- }
2111- select {
2112- case <-win.flush:
2113- case <-time.After(time.Second):
2114- t.Fatal("same-read synchronized update end did not request immediate flush")
2115- }
2116-}
2117-
2118-func TestPTYReaderDefersDirtyDuringSynchronizedUpdateAndFlushesAtEnd(t *testing.T) {
2119- d := newTestDaemon(t, nil, stubClock{})
2120- p := portsmocks.NewMockPTY(t)
2121- chunks := [][]byte{[]byte("\x1b[?2026hhello"), []byte(" world\x1b[?2026l")}
2122- p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
2123- if len(chunks) == 0 {
2124- return 0, io.EOF
2125- }
2126- n := copy(buf, chunks[0])
2127- chunks = chunks[1:]
2128- return n, nil
2129- })
2130- p.EXPECT().Close().Return(nil).Maybe()
2131-
2132- sctx, cancel := context.WithCancel(context.Background())
2133- 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}
2134- sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
2135- d.sessions[sess.id] = sess
2136- d.sessWg.Add(1)
2137- d.ptyReader(sess, win)
2138-
2139- select {
2140- case <-win.dirty:
2141- t.Fatal("dirty signaled while synchronized update was active")
2142- default:
2143- }
2144- select {
2145- case <-win.flush:
2146- case <-time.After(time.Second):
2147- t.Fatal("sync end did not request immediate flush")
2148- }
2149-}
2150-
2151-func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) {
2152- clk := &signalClock{timers: make(chan *signalTimer, 1)}
2153- d := newTestDaemon(t, nil, clk)
2154- p := portsmocks.NewMockPTY(t)
2155- release := make(chan struct{})
2156- read := 0
2157- p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
2158- if read == 0 {
2159- read++
2160- return copy(buf, []byte("\x1b[?2026habandoned")), nil
2161- }
2162- <-release
2163- return 0, io.EOF
2164- })
2165- p.EXPECT().Close().Return(nil).Maybe()
2166-
2167- tr, sends := newCapturingTransport(t)
2168- sctx, cancel := context.WithCancel(context.Background())
2169- 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}
2170- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
2171- sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
2172- ac.setSession(sess)
2173- d.sessions[sess.id] = sess
2174- d.sessWg.Add(2)
2175- go d.scheduler(sess, win)
2176- go d.ptyReader(sess, win)
2177-
2178- timer := <-clk.timers
2179- timer.ch <- time.Now()
2180- awaitFrame(t, sends, ports.MsgOutput)
2181-
2182- win.mu.Lock()
2183- active := win.screen.SyncUpdateActive()
2184- got := screenLineText(win.screen, 0)
2185- win.mu.Unlock()
2186- require.False(t, active)
2187- require.Contains(t, got, "abandoned")
2188-
2189- close(release)
2190- cancel()
2191- d.sessWg.Wait()
2192-}
2193-
2194-func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
2195- clk := &signalClock{timers: make(chan *signalTimer, 1)}
2196- d := newTestDaemon(t, nil, clk)
2197- p := portsmocks.NewMockPTY(t)
2198- release := make(chan struct{})
2199- chunks := [][]byte{[]byte("\x1b[?2026hhello"), []byte(" world\x1b[?2026l")}
2200- p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
2201- if len(chunks) > 0 {
2202- n := copy(buf, chunks[0])
2203- chunks = chunks[1:]
2204- return n, nil
2205- }
2206- <-release
2207- return 0, io.EOF
2208- })
2209- p.EXPECT().Close().Return(nil).Maybe()
2210-
2211- tr, sends := newCapturingTransport(t)
2212- sctx, cancel := context.WithCancel(context.Background())
2213- 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}
2214- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
2215- sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
2216- ac.setSession(sess)
2217- d.sessions[sess.id] = sess
2218- d.sessWg.Add(2)
2219- go d.scheduler(sess, win)
2220- go d.ptyReader(sess, win)
2221-
2222- timer := <-clk.timers
2223- awaitFrame(t, sends, ports.MsgOutput)
2224- timer.ch <- time.Now()
2225-
2226- select {
2227- case f := <-sends:
2228- if f.Type == ports.MsgOutput {
2229- t.Fatal("stale synchronized update watchdog produced an extra render")
2230- }
2231- case <-time.After(50 * time.Millisecond):
2232- }
2233- win.mu.Lock()
2234- active := win.screen.SyncUpdateActive()
2235- win.mu.Unlock()
2236- require.False(t, active)
2237-
2238- close(release)
2239- cancel()
2240- d.sessWg.Wait()
2241-}
2242-
2243-func mustOutputData(t *testing.T, sends chan ports.Frame) []byte {
2244- t.Helper()
329+func mustOutputData(t *testing.T, sends chan ports.Frame) []byte {
330+ t.Helper()
2245331 f := awaitFrame(t, sends, ports.MsgOutput)
2246332 out, err := ports.UnmarshalOutput(f.Payload)
2247333 require.NoError(t, err)
2248334 return out.Data
2249335 }
2250-
2251-func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
2252- writes := make(chan []byte, 4)
2253- p, _ := newBlockingPTYWithWrites(t, writes)
2254- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
2255- win := sess.tabs[0]
2256- win.screen.Write([]byte("live"))
2257-
2258- d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
2259- data := mustOutputData(t, sends)
2260- require.NotNil(t, ac.copyMode)
2261- require.Equal(t, 19, ac.copyMode.Cursor)
2262- require.Contains(t, string(data), "[SCROLL]")
2263-
2264- d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
2265- mustOutputData(t, sends)
2266- require.Nil(t, ac.copyMode)
2267-
2268- d.handleInput(sess, ac, []byte("\x1b[<64;1;1Mq"))
2269- mustOutputData(t, sends)
2270- mustOutputData(t, sends)
2271- require.Nil(t, ac.copyMode, "q after wheel in same input must be routed after copy mode is entered")
2272- select {
2273- case got := <-writes:
2274- t.Fatalf("mouse/copy input forwarded to PTY: %q", got)
2275- default:
2276- }
2277-}
2278-
2279-func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
2280- writes := make(chan []byte, 2)
2281- p, _ := newBlockingPTYWithWrites(t, writes)
2282- d, sess, ac, _ := newManualSessionWithPTYs(t, p)
2283- sess.tabs[0].screen.Write([]byte("\x1b[?1049h"))
2284-
2285- d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
2286- d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
2287-
2288- require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes)
2289- require.Equal(t, []byte("\x1b[B\x1b[B\x1b[B"), <-writes)
2290- require.Nil(t, ac.copyMode)
2291-}
2292-
2293-func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
2294- writes := make(chan []byte, 4)
2295- p, _ := newBlockingPTYWithWrites(t, writes)
2296- d, sess, ac, _ := newManualSessionWithPTYs(t, p)
2297-
2298- d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
2299- select {
2300- case got := <-writes:
2301- t.Fatalf("press without child mouse mode forwarded: %q", got)
2302- default:
2303- }
2304-
2305- sess.tabs[0].screen.Write([]byte("\x1b[?1000h"))
2306- raw := []byte("\x1b[<0;2;3M")
2307- d.handleInput(sess, ac, raw)
2308- select {
2309- case got := <-writes:
2310- t.Fatalf("SGR report forwarded to child without SGR mode: %q", got)
2311- default:
2312- }
2313-
2314- sess.tabs[0].screen.Write([]byte("\x1b[?1006h"))
2315- d.handleInput(sess, ac, raw)
2316- require.Equal(t, raw, <-writes)
2317-
2318- d.handleInput(sess, ac, []byte("\x1b[<0;1;24M"))
2319- select {
2320- case got := <-writes:
2321- t.Fatalf("status-row mouse report forwarded: %q", got)
2322- default:
2323- }
2324-}
2325-
2326-func TestMouseSplitReportPreservesOrder(t *testing.T) {
2327- writes := make(chan []byte, 2)
2328- p, _ := newBlockingPTYWithWrites(t, writes)
2329- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
2330- sess.tabs[0].screen.Write([]byte("live"))
2331-
2332- d.handleInput(sess, ac, []byte("\x1b[<64;"))
2333- d.handleInput(sess, ac, []byte("1;1Mq"))
2334-
2335- mustOutputData(t, sends)
2336- mustOutputData(t, sends)
2337- require.Nil(t, ac.copyMode)
2338- select {
2339- case got := <-writes:
2340- t.Fatalf("split mouse/copy bytes forwarded to PTY: %q", got)
2341- default:
2342- }
2343-}
2344-
2345-func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
2346- p, _ := newBlockingPTY(t)
2347- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
2348- win := sess.tabs[0]
2349- win.screen.Write([]byte("A"))
2350-
2351- d.paint(sess, ac, true)
2352- data := mustOutputData(t, sends)
2353- require.Contains(t, string(data), "\x1b[5 q")
2354- require.Contains(t, string(data), "\x1b[?25h")
2355-
2356- win.screen.Write([]byte("\x1b[2;3H"))
2357- d.paint(sess, ac, false)
2358- data = mustOutputData(t, sends)
2359- require.Contains(t, string(data), "\x1b[2;3H")
2360- require.Contains(t, string(data), "\x1b[?25h")
2361-
2362- win.screen.Write([]byte("\x1b[?25l"))
2363- d.paint(sess, ac, false)
2364- data = mustOutputData(t, sends)
2365- require.Contains(t, string(data), "\x1b[?25l")
2366-}
2367-
2368-func TestCursorTailHidesInCopyMode(t *testing.T) {
2369- p, _ := newBlockingPTY(t)
2370- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
2371- sess.tabs[0].screen.Write([]byte("live"))
2372-
2373- d.enterCopyMode(sess, ac)
2374- data := mustOutputData(t, sends)
2375- require.Contains(t, string(data), "\x1b[?25l")
2376-}
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+}
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+}
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+}
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
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+}

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