1package daemon
2
3import (
4 "context"
5 "errors"
6 "io"
7 "log/slog"
8 "sync"
9 "testing"
10 "time"
11
12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"
14
15 renderer "github.com/bnema/vev-vt"
16 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/domain/layout"
18 "github.com/bnema/vev/internal/ports"
19 portsmocks "github.com/bnema/vev/internal/ports/mocks"
20 "github.com/bnema/vev/internal/protocol/wire"
21 scopy "github.com/bnema/vev/internal/usecase/copy"
22)
23
24func TestLayoutGenerationMutationBookkeeping(t *testing.T) {
25 t.Run("focus and expanded member share one logical bump", func(t *testing.T) {
26 tb := newTab(nil, domain.Size{Cols: 41, Rows: 10})
27 second := newPane("pane-2", nil, domain.Size{Cols: 41, Rows: 10})
28 tb.mu.Lock()
29 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-1"}, Focus: "pane-1"}
30 tb.panes[second.id] = second
31 require.Zero(t, tb.layoutGeneration)
32 require.True(t, focusPlacementLocked(tb, second.id))
33 require.Equal(t, uint64(1), tb.layoutGeneration)
34 require.False(t, focusPlacementLocked(tb, second.id))
35 require.Equal(t, uint64(1), tb.layoutGeneration)
36 tb.mu.Unlock()
37 })
38
39 t.Run("size changes bump only when the value changes", func(t *testing.T) {
40 tb := newTab(nil, domain.Size{Cols: 41, Rows: 10})
41 tb.mu.Lock()
42 if tb.size != (domain.Size{Cols: 80, Rows: 23}) {
43 tb.size = domain.Size{Cols: 80, Rows: 23}
44 tb.bumpLayoutGenerationLocked()
45 }
46 require.Equal(t, uint64(1), tb.layoutGeneration)
47 if tb.size != (domain.Size{Cols: 80, Rows: 23}) {
48 tb.size = domain.Size{Cols: 80, Rows: 23}
49 tb.bumpLayoutGenerationLocked()
50 }
51 require.Equal(t, uint64(1), tb.layoutGeneration)
52 tb.mu.Unlock()
53 })
54}
55
56type layoutRetryClock struct{ timers chan *layoutRetryTimer }
57
58type layoutRetryTimer struct {
59 ch chan time.Time
60 delay time.Duration
61}
62
63func (c *layoutRetryClock) Now() time.Time { return time.Unix(0, 0) }
64
65func (c *layoutRetryClock) NewTimer(delay time.Duration) ports.Timer {
66 timer := &layoutRetryTimer{ch: make(chan time.Time, 1), delay: delay}
67 c.timers <- timer
68 return timer
69}
70
71func (t *layoutRetryTimer) C() <-chan time.Time { return t.ch }
72func (*layoutRetryTimer) Reset(time.Duration) bool { return false }
73func (*layoutRetryTimer) Stop() bool { return true }
74func (t *layoutRetryTimer) fire() { t.ch <- time.Unix(0, 0) }
75
76func TestLayoutRetryIsBoundedDeduplicatedAndCanceled(t *testing.T) {
77 t.Run("persistent failure is bounded and reports once", func(t *testing.T) {
78 pty := &transactionalResizePTY{errs: []error{errors.New("initial"), errors.New("retry 1"), errors.New("retry 2"), errors.New("retry 3")}}
79 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
80 clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)}
81 d.clock = clock
82 tb := testAttachmentTab(sess)
83 tb.mu.Lock()
84 tb.size = domain.Size{Cols: 100, Rows: 20}
85 tb.bumpLayoutGenerationLocked()
86 tb.mu.Unlock()
87
88 sess.geometry.applyTabLayout(d, sess, tb)
89 // A second accepted failure joins the existing worker rather than adding
90 // another timer/goroutine.
91 sess.geometry.scheduleAcceptedTabLayoutRetry(d, sess, tb)
92 for range maxAcceptedTabLayoutRetries {
93 var timer *layoutRetryTimer
94 for timer == nil {
95 candidate := <-clock.timers
96 if candidate.delay == minOutputRenderDeadline {
97 timer = candidate
98 }
99 }
100 timer.fire()
101 }
102 require.Eventually(t, func() bool {
103 tb.layoutRetryMu.Lock()
104 defer tb.layoutRetryMu.Unlock()
105 return !tb.layoutRetryRunning
106 }, time.Second, time.Millisecond)
107 require.Len(t, pty.requested(), 1+maxAcceptedTabLayoutRetries)
108 count := 0
109 for _, notice := range d.notices.history() {
110 if notice.Code == domain.NoticeResizeFailed {
111 count++
112 }
113 }
114 require.Equal(t, 1, count, "background retries must not create a notification storm")
115 })
116
117 t.Run("tab cancellation stops a waiting retry", func(t *testing.T) {
118 pty := &transactionalResizePTY{errs: []error{errors.New("initial")}}
119 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
120 clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)}
121 d.clock = clock
122 tb := testAttachmentTab(sess)
123 tb.mu.Lock()
124 tb.size = domain.Size{Cols: 100, Rows: 20}
125 tb.bumpLayoutGenerationLocked()
126 tb.mu.Unlock()
127 sess.geometry.applyTabLayout(d, sess, tb)
128 var timer *layoutRetryTimer
129 for timer == nil {
130 candidate := <-clock.timers
131 if candidate.delay == minOutputRenderDeadline {
132 timer = candidate
133 }
134 }
135 tb.cancel()
136 timer.fire()
137 require.Eventually(t, func() bool {
138 tb.layoutRetryMu.Lock()
139 defer tb.layoutRetryMu.Unlock()
140 return !tb.layoutRetryRunning
141 }, time.Second, time.Millisecond)
142 require.Len(t, pty.requested(), 1)
143 })
144}
145
146func TestFocusDirAtDoesNotApplyUncommittedCandidate(t *testing.T) {
147 pty := &transactionalResizePTY{}
148 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
149 tb := testAttachmentTab(sess)
150 second := newPane("pane-2", nil, domain.Size{Cols: 80, Rows: 23})
151 tb.mu.Lock()
152 tb.panes[second.id] = second
153 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
154 tb.panes["pane-1"].resizeRetry = true
155 target := tb.panes["pane-1"]
156 tb.mu.Unlock()
157
158 _, err := d.focusDirAt(sess, tb, target, layout.Right)
159 require.NoError(t, err)
160 require.Empty(t, pty.requested(), "an equality-mismatched candidate was not committed and must not apply")
161}
162
163func TestLayoutApplicationRetriesOnlyAcceptedFailedGeometry(t *testing.T) {
164 pty := &transactionalResizePTY{errs: []error{errors.New("first resize fails")}}
165 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
166 clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)}
167 d.clock = clock
168 tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
169 tb.mu.Lock()
170 tb.size = domain.Size{Cols: 100, Rows: 20}
171 tb.bumpLayoutGenerationLocked()
172 tb.mu.Unlock()
173
174 secondResize := make(chan struct{})
175 var resized sync.Once
176 pty.onResize = func() {
177 if len(pty.requested()) == 2 {
178 resized.Do(func() { close(secondResize) })
179 }
180 }
181 sess.geometry.applyTabLayout(d, sess, tb)
182
183 var timer *layoutRetryTimer
184 for timer == nil {
185 candidate := <-clock.timers
186 if candidate.delay == minOutputRenderDeadline {
187 timer = candidate
188 }
189 }
190 require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}}, pty.requested())
191 require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}, "failed apply must not publish a VT resize")
192 timer.fire()
193 awaitSignal(t, secondResize, "accepted failure was not retried")
194 require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}, {Cols: 100, Rows: 20}}, pty.requested())
195 require.Eventually(t, func() bool {
196 p.mu.Lock()
197 defer p.mu.Unlock()
198 return p.screen.Columns() == 100 && p.screen.Rows() == 20
199 }, time.Second, time.Millisecond, "successful retry did not publish its VT size")
200}
201
202func TestDelayedRetryUsesFreshLayoutAfterPaneResize(t *testing.T) {
203 clock := newCoordinatorMockClock(t, 8)
204 // The failed apply also posts its preserved degradation warning; its toast
205 // lifetime is unrelated to the coordinator deadlines driven below.
206 clock.clock.EXPECT().NewTimer(6 * time.Second).Return(stubTimer{}).Once()
207 failed := &transactionalResizePTY{errs: []error{errors.New("first resize fails")}}
208 d, sess, ac, _ := newManualSessionWithPTYs(t, failed)
209 d.clock = clock.clock
210 tb := testAttachmentTab(sess)
211 first := tb.focusedPane()
212 second := newPane("pane-2", nil, domain.Size{Cols: 80, Rows: 23})
213 tb.mu.Lock()
214 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(first.id), layout.NewLeaf(second.id)}}, Focus: first.id}
215 tb.panes[second.id] = second
216 tb.bumpLayoutGenerationLocked()
217 tb.mu.Unlock()
218
219 // A client resize commits the new layout but leaves the failed PTY on the
220 // coordinator retry lane. Drive both deadlines explicitly so this ordering
221 // cannot depend on wall-clock scheduling.
222 d.resize(sess, ac, domain.Size{Cols: 80, Rows: 24})
223 rc := sess.renderCoordinator()
224 resizeTimer := awaitCoordinatorScheduledTimer(t, clock)
225 resizeDone := captureResizeCallbackDone(t, rc)
226 resizeTimer.ch <- time.Time{}
227 awaitTestCompletion(t, resizeDone, "failed client resize did not complete")
228 var retryTimer *coordinatorMockTimer
229 for retryTimer == nil {
230 candidate := awaitCoordinatorScheduledTimer(t, clock)
231 if candidate.duration == minOutputRenderDeadline {
232 retryTimer = candidate
233 }
234 }
235
236 // A later pane-resize changes the solved rectangle and repairs the failed
237 // PTY before the delayed callback fires. Retrying its old captured 40-column
238 // rectangle would regress the PTY, VT, and published pane geometry.
239 require.NoError(t, d.resizePane(daemonActionTarget{session: sess, tab: tb, pane: first}, layout.Width, resizeStepCols))
240 tb.mu.Lock()
241 placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
242 currentFirst := placementContent(placements, first.id)
243 currentSecond := placementContent(placements, second.id)
244 tb.mu.Unlock()
245 require.True(t, ok)
246 require.Equal(t, currentFirst, first.rect)
247 require.Equal(t, currentSecond, second.rect)
248 require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Columns(), Rows: first.screen.Rows()})
249 require.Equal(t, []domain.Size{{Cols: 40, Rows: 22}, rectSize(currentFirst)}, failed.requested())
250
251 rc.mu.Lock()
252 retryDone := rc.retryLane.token.done
253 rc.mu.Unlock()
254 retryTimer.ch <- time.Time{}
255 awaitTestCompletion(t, retryDone, "delayed resize retry did not complete")
256
257 require.Equal(t, []domain.Size{{Cols: 40, Rows: 22}, rectSize(currentFirst)}, failed.requested(), "delayed retry must not apply stale PTY geometry")
258 require.Equal(t, currentFirst, first.rect, "delayed retry must not publish a stale rectangle")
259 require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Columns(), Rows: first.screen.Rows()}, "delayed retry must not publish a stale VT size")
260}
261
262func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) {
263 d, sess, pty, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
264 tb := testAttachmentTab(sess)
265 tb.focusedPane().rect = domain.Rect{Width: 20, Height: 10}
266 entered := make(chan struct{})
267 release := make(chan struct{})
268 pty.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 41, Rows: 10}}).Run(func(domain.Geometry) {
269 close(entered)
270 <-release
271 }).Return(nil).Once()
272
273 done := make(chan struct{})
274 go func() {
275 sess.geometry.applyTabLayout(d, sess, tb)
276 close(done)
277 }()
278 awaitSignal(t, entered, "PTY resize did not start")
279
280 locked := make(chan struct{})
281 go func() {
282 tb.mu.Lock()
283 _ = tb.layoutGeneration
284 tb.mu.Unlock()
285 close(locked)
286 }()
287 awaitSignal(t, locked, "tab lock remained held across PTY resize")
288 close(release)
289 awaitSignal(t, done, "layout application did not finish")
290}
291
292func TestLayoutApplicationRejectsStalePaneIdentity(t *testing.T) {
293 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
294 tb := testAttachmentTab(sess)
295 oldPane := tb.focusedPane()
296 oldPane.rect = domain.Rect{Width: 20, Height: 10}
297 oldPane.screen.Resize(20, 10)
298 entered := make(chan struct{})
299 release := make(chan struct{})
300 oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 41, Rows: 10}}).Run(func(domain.Geometry) {
301 close(entered)
302 <-release
303 }).Return(nil).Once()
304
305 done := make(chan struct{})
306 go func() {
307 sess.geometry.applyTabLayout(d, sess, tb)
308 close(done)
309 }()
310 awaitSignal(t, entered, "stale PTY resize did not start")
311
312 newPTY := portsmocks.NewMockPTY(t)
313 replacement := newPane("pane-1", newPTY, domain.Size{Cols: 20, Rows: 10})
314 newPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 41, Rows: 10}}).Return(nil).Once()
315 tb.mu.Lock()
316 tb.panes["pane-1"] = replacement
317 tb.bumpLayoutGenerationLocked()
318 tb.mu.Unlock()
319 close(release)
320 awaitSignal(t, done, "stale layout did not retry")
321
322 require.Equal(t, domain.Rect{Width: 20, Height: 10}, oldPane.rect, "stale pane rectangle was published")
323 require.Equal(t, 20, oldPane.screen.Columns(), "stale pane screen was published")
324 require.Equal(t, domain.Rect{Width: 41, Height: 10}, replacement.rect)
325 require.Equal(t, 41, replacement.screen.Columns())
326}
327
328func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
329 newBlockedPTY := func() (*transactionalResizePTY, <-chan struct{}, chan<- struct{}) {
330 entered := make(chan struct{})
331 release := make(chan struct{})
332 var once sync.Once
333 return &transactionalResizePTY{onResize: func() {
334 once.Do(func() {
335 close(entered)
336 <-release
337 })
338 }}, entered, release
339 }
340
341 t.Run("close removes a stale plan member", func(t *testing.T) {
342 first, entered, release := newBlockedPTY()
343 second := &transactionalResizePTY{}
344 d, sess, _, _ := newManualSessionWithPTYs(t, first)
345 tb := testAttachmentTab(sess)
346 p1 := tb.focusedPane()
347 p2 := newPane("pane-2", second, domain.Size{Cols: 80, Rows: 23})
348 tb.mu.Lock()
349 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}}, Focus: p1.id}
350 tb.panes[p2.id] = p2
351 tb.bumpLayoutGenerationLocked()
352 tb.mu.Unlock()
353
354 done := make(chan struct{})
355 go func() { sess.geometry.applyTabLayout(d, sess, tb); close(done) }()
356 awaitSignal(t, entered, "old layout apply did not block")
357 closed := make(chan error, 1)
358 go func() { closed <- d.closePane(sess, tb, p2.id, nil, false) }()
359 require.Eventually(t, func() bool {
360 tb.mu.Lock()
361 defer tb.mu.Unlock()
362 return tb.panes[p2.id] == nil && tb.layoutGeneration > 1
363 }, time.Second, time.Millisecond, "close must mutate before its serialized apply waits")
364 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p1.rect, "stale plan published before release")
365 close(release)
366 awaitSignal(t, done, "stale layout apply did not finish")
367 require.NoError(t, <-closed)
368
369 tb.mu.Lock()
370 _, exists := tb.panes[p2.id]
371 tb.mu.Unlock()
372 require.False(t, exists)
373 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p2.rect, "removed pane received a stale rectangle publication")
374 require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p2.screen.Columns(), Rows: p2.screen.Rows()}, "removed pane received a stale screen publication")
375 p2.mu.Lock()
376 require.False(t, p2.resizeApplying, "removed pane gate was not cancelled")
377 p2.mu.Unlock()
378 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p1.rect)
379 require.Equal(t, []domain.Size{{Cols: 40, Rows: 23}, {Cols: 80, Rows: 23}}, first.requested(), "the rejected plan must be followed by a fresh surviving-pane apply")
380 require.Equal(t, []domain.Size{{Cols: 39, Rows: 23}}, second.requested(), "the removed pane may receive an obsolete external resize but must never be published")
381 })
382
383 t.Run("focus expansion replaces a stale stack placement", func(t *testing.T) {
384 first, entered, release := newBlockedPTY()
385 second := &transactionalResizePTY{}
386 d, sess, _, _ := newManualSessionWithPTYs(t, first)
387 tb := testAttachmentTab(sess)
388 p1 := tb.focusedPane()
389 p2 := newPane("pane-2", second, domain.Size{Cols: 80, Rows: 23})
390 tb.mu.Lock()
391 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p1.id}, Focus: p1.id}
392 tb.panes[p2.id] = p2
393 tb.bumpLayoutGenerationLocked()
394 tb.mu.Unlock()
395
396 done := make(chan struct{})
397 go func() { sess.geometry.applyTabLayout(d, sess, tb); close(done) }()
398 awaitSignal(t, entered, "old stack apply did not block")
399 tb.mu.Lock()
400 require.True(t, focusPlacementLocked(tb, p2.id))
401 tb.mu.Unlock()
402 close(release)
403 awaitSignal(t, done, "stale stack apply did not retry")
404
405 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p1.rect, "collapsed pane received stale geometry")
406 p1.mu.Lock()
407 require.False(t, p1.resizeApplying, "collapsed pane gate was not cancelled")
408 p1.mu.Unlock()
409 tb.mu.Lock()
410 placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
411 tb.mu.Unlock()
412 require.True(t, ok)
413 require.Equal(t, placementContent(placements, p2.id), p2.rect)
414 require.Equal(t, []domain.Size{{Cols: 80, Rows: 22}}, first.requested(), "the old expanded pane is applied only by the rejected attempt")
415 require.Equal(t, []domain.Size{rectSize(p2.rect)}, second.requested())
416 })
417
418 t.Run("client size rejects and retries a blocked plan", func(t *testing.T) {
419 pty, entered, release := newBlockedPTY()
420 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
421 tb := testAttachmentTab(sess)
422 p := tb.focusedPane()
423 p.rect = domain.Rect{Width: 20, Height: 10}
424
425 oldDone := make(chan struct{})
426 go func() { sess.geometry.applyTabLayout(d, sess, tb); close(oldDone) }()
427 awaitSignal(t, entered, "old size apply did not block")
428 resizeDone := make(chan bool, 1)
429 go func() {
430 resizeDone <- sess.geometry.requestResize(d, sess, nil, domain.Size{Cols: 100, Rows: 30}, true)
431 }()
432 // Session resize now stages the target privately until every external
433 // apply validates and the transaction is admitted, so neither the size
434 // nor pane geometry is visible while the old PTY call is blocked.
435 tb.mu.Lock()
436 require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size)
437 tb.mu.Unlock()
438 require.Equal(t, domain.Rect{Width: 20, Height: 10}, p.rect, "stale size published before release")
439 close(release)
440 awaitSignal(t, oldDone, "old size apply did not finish")
441 require.True(t, <-resizeDone)
442 require.Equal(t, domain.Rect{Width: 100, Height: 28}, p.rect)
443 require.Equal(t, domain.Size{Cols: 100, Rows: 28}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()})
444 require.Equal(t, []domain.Size{{Cols: 80, Rows: 23}, {Cols: 100, Rows: 28}}, pty.requested())
445 })
446}
447
448func awaitSignal(t *testing.T, ch <-chan struct{}, message string) {
449 t.Helper()
450 select {
451 case <-ch:
452 case <-time.After(2 * time.Second):
453 t.Fatal(message)
454 }
455}
456
457func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
458 tests := []struct {
459 name string
460 dir layout.Direction
461 size domain.Size
462 wantNew domain.Rect
463 wantOld domain.Rect
464 isNewSide func(oldRect, newRect domain.Rect) bool
465 }{
466 {name: "right", dir: layout.Right, size: domain.Size{Cols: 41, Rows: 10}, wantOld: domain.Rect{Width: 20, Height: 10}, wantNew: domain.Rect{X: 21, Width: 20, Height: 10}, isNewSide: func(oldRect, newRect domain.Rect) bool { return newRect.X > oldRect.X }},
467 {name: "left", dir: layout.Left, size: domain.Size{Cols: 41, Rows: 10}, wantOld: domain.Rect{X: 21, Width: 20, Height: 10}, wantNew: domain.Rect{Width: 20, Height: 10}, isNewSide: func(oldRect, newRect domain.Rect) bool { return newRect.X < oldRect.X }},
468 {name: "down", dir: layout.Down, size: domain.Size{Cols: 80, Rows: 5}, wantOld: domain.Rect{Width: 80, Height: 2}, wantNew: domain.Rect{Y: 3, Width: 80, Height: 2}, isNewSide: func(oldRect, newRect domain.Rect) bool { return newRect.Y > oldRect.Y }},
469 {name: "up", dir: layout.Up, size: domain.Size{Cols: 80, Rows: 5}, wantOld: domain.Rect{Y: 3, Width: 80, Height: 2}, wantNew: domain.Rect{Width: 80, Height: 2}, isNewSide: func(oldRect, newRect domain.Rect) bool { return newRect.Y < oldRect.Y }},
470 }
471
472 for _, tt := range tests {
473 t.Run(tt.name, func(t *testing.T) {
474 d, sess, oldPTY, factory := newSplitTestDaemon(t, tt.size)
475 newPTY := portsmocks.NewMockPTY(t)
476 oldPTY.EXPECT().Resize(domain.Geometry{Size: rectSize(tt.wantOld)}).Return(nil).Once()
477 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
478 factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Geometry{Size: rectSize(tt.wantNew)}).Return(newPTY, nil).Once()
479
480 require.NoError(t, d.splitPane(sess, nil, tt.dir))
481
482 tb := testAttachmentTab(sess)
483 tb.mu.Lock()
484 defer tb.mu.Unlock()
485 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
486 require.Len(t, tb.panes, 2)
487 oldRect := tb.panes["pane-1"].rect
488 newRect := tb.panes["pane-2"].rect
489 require.Equal(t, tt.wantOld, oldRect)
490 require.Equal(t, tt.wantNew, newRect)
491 require.True(t, tt.isNewSide(oldRect, newRect))
492 })
493 }
494}
495
496func TestSplitPaneActionFocusesNewPaneForInitiatingAttachment(t *testing.T) {
497 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
498 ac := registryTestAttachment(1)
499 other := registryTestAttachment(2)
500 require.True(t, sess.registerAttachment(ac))
501 require.True(t, sess.registerAttachment(other))
502 newPTY := portsmocks.NewMockPTY(t)
503 oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once()
504 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
505 factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(newPTY, nil).Once()
506
507 target := resolveDaemonActionTargetForAttachment(sess, ac)
508 require.NoError(t, (daemonActions{d: d}).Run(daemonActionRequest{
509 kind: daemonActionSplitPane,
510 target: target,
511 direction: layout.Right,
512 }))
513
514 _, focused := sess.paneForAttachment(ac)
515 require.NotNil(t, focused)
516 require.Equal(t, layout.PaneID("pane-2"), focused.id)
517 _, otherFocused := sess.paneForAttachment(other)
518 require.NotNil(t, otherFocused)
519 require.Equal(t, layout.PaneID("pane-1"), otherFocused.id)
520
521 target = resolveDaemonActionTargetForAttachment(sess, ac)
522 require.NoError(t, (daemonActions{d: d}).Run(daemonActionRequest{
523 kind: daemonActionFocusPane,
524 target: target,
525 direction: layout.Left,
526 }))
527 _, focused = sess.paneForAttachment(ac)
528 require.NotNil(t, focused)
529 require.Equal(t, layout.PaneID("pane-1"), focused.id)
530}
531
532func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
533 d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 4})
534 tb := testAttachmentTab(sess)
535 stackPTY := portsmocks.NewMockPTY(t)
536 stackPane := newPane("pane-2", stackPTY, domain.Size{Cols: 41, Rows: 2})
537 tb.panes[stackPane.id] = stackPane
538 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-2"}, Focus: "pane-2"}
539 tb.nextPaneID = 3
540 newPTY := portsmocks.NewMockPTY(t)
541 stackPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 3}}).Return(nil).Once()
542 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
543 factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Geometry{Size: domain.Size{Cols: 20, Rows: 4}}).Return(newPTY, nil).Once()
544
545 require.NoError(t, d.splitPane(sess, nil, layout.Right))
546
547 tb.mu.Lock()
548 defer tb.mu.Unlock()
549 placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
550 require.True(t, ok)
551 require.Equal(t, layout.PaneID("pane-3"), tb.tree.Focus)
552 require.Len(t, tb.panes, 3)
553 require.Equal(t, domain.Rect{Y: 1, Width: 20, Height: 3}, placementContent(placements, "pane-2"))
554 require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 4}, placementContent(placements, "pane-3"))
555}
556
557func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {
558 d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
559 cause := errors.New("open failed")
560 factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil, cause).Once()
561
562 tb := testAttachmentTab(sess)
563 tb.mu.Lock()
564 beforeRoot, beforeFocus, beforeNext, beforePaneCount := tb.tree.Root.Leaf, tb.tree.Focus, tb.nextPaneID, len(tb.panes)
565 tb.mu.Unlock()
566
567 err := d.splitPane(sess, nil, layout.Right)
568 require.Error(t, err)
569
570 var ue *domain.UserError
571 require.ErrorAs(t, err, &ue)
572 require.Equal(t, domain.NoticePaneSpawn, ue.Code)
573 require.Equal(t, domain.NoticeError, ue.Severity)
574 require.Equal(t, "couldn't open pane: shell failed to start", ue.Msg)
575 require.ErrorIs(t, err, cause)
576 require.NotContains(t, ue.Msg, cause.Error())
577
578 tb.mu.Lock()
579 defer tb.mu.Unlock()
580 require.Equal(t, beforeRoot, tb.tree.Root.Leaf)
581 require.Equal(t, beforeFocus, tb.tree.Focus)
582 require.Equal(t, beforeNext+1, tb.nextPaneID)
583 require.Len(t, tb.panes, beforePaneCount)
584 require.Nil(t, tb.panes["pane-2"])
585}
586
587func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) {
588 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
589 tb := testAttachmentTab(sess)
590 newPTY := portsmocks.NewMockPTY(t)
591 newPane := newPane("pane-2", newPTY, domain.Size{Cols: 41, Rows: 10})
592 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
593 tb.panes["pane-2"] = newPane
594 oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once()
595 newPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once()
596
597 sess.geometry.applyTabLayout(d, sess, tb)
598
599 require.Equal(t, 20, tb.panes["pane-1"].screen.Columns())
600 require.Equal(t, 20, tb.panes["pane-2"].screen.Columns())
601 require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 10}, tb.panes["pane-2"].rect)
602}
603
604func TestResizeReflowsAllPanes(t *testing.T) {
605 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
606 tb := testAttachmentTab(sess)
607 newPTY := portsmocks.NewMockPTY(t)
608 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
609 tb.panes["pane-2"] = newPane("pane-2", newPTY, domain.Size{Cols: 20, Rows: 10})
610 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
611 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
612 oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 30, Rows: 18}}).Return(nil).Once()
613 newPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 29, Rows: 18}}).Return(nil).Once()
614
615 d.resize(sess, nil, domain.Size{Cols: 60, Rows: 20})
616
617 require.Equal(t, domain.Size{Cols: 60, Rows: 18}, tb.size)
618 require.Equal(t, domain.Rect{Width: 30, Height: 18}, tb.panes["pane-1"].rect)
619 require.Equal(t, domain.Rect{X: 31, Width: 29, Height: 18}, tb.panes["pane-2"].rect)
620 require.Equal(t, 30, tb.panes["pane-1"].screen.Columns())
621 require.Equal(t, 29, tb.panes["pane-2"].screen.Columns())
622}
623
624func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) {
625 tests := []struct {
626 name string
627 direction layout.Direction
628 wantErr error
629 wantFocus layout.PaneID
630 }{
631 {name: "moves right", direction: layout.Right, wantFocus: "pane-2"},
632 {name: "maps missing left neighbor", direction: layout.Left, wantErr: errNoNeighbor, wantFocus: "pane-1"},
633 }
634
635 for _, tt := range tests {
636 t.Run(tt.name, func(t *testing.T) {
637 d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
638 tb := testAttachmentTab(sess)
639 target := tb.panes["pane-1"]
640 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
641 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
642 tb.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
643 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
644
645 change, err := d.focusDirAt(sess, tb, target, tt.direction)
646
647 require.ErrorIs(t, err, tt.wantErr)
648 require.Equal(t, domain.Rect{Width: 20, Height: 10}, change.span)
649 require.Equal(t, tt.wantFocus, tb.tree.Focus)
650 })
651 }
652}
653
654func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
655 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
656 tb := testAttachmentTab(sess)
657 newPTY := portsmocks.NewMockPTY(t)
658 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
659 tb.panes["pane-2"] = newPane("pane-2", newPTY, domain.Size{Cols: 20, Rows: 10})
660 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
661 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
662 tr, _ := newCapturingTransport(t)
663 ac := &attachedClient{tr: tr, output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 41, Rows: 12}}}
664 ac.initOverlays()
665 ac.setSession(sess)
666 require.True(t, sess.registerAttachment(ac))
667 ac.overlays.copyMode = &scopy.Mode{}
668
669 require.NoError(t, d.focusDir(sess, ac, layout.Right, nil))
670
671 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
672 require.Nil(t, ac.overlays.copyMode)
673 oldPTY.AssertNotCalled(t, "Resize", mock.Anything)
674 newPTY.AssertNotCalled(t, "Resize", mock.Anything)
675}
676
677func TestClosePaneRepaintFanoutRespectsAttachmentScope(t *testing.T) {
678 for _, tt := range []struct {
679 name string
680 attachment *attachedClient
681 wantFirst bool
682 wantSecond bool
683 }{
684 {name: "nil attachment fans out", wantFirst: true, wantSecond: true},
685 {name: "non-nil attachment stays targeted"},
686 } {
687 t.Run(tt.name, func(t *testing.T) {
688 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
689 tb := testAttachmentTab(sess)
690 closingPTY := portsmocks.NewMockPTY(t)
691 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
692 tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})
693 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
694 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
695 oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 41, Rows: 10}}).Return(nil).Once()
696 closingPTY.EXPECT().Close().Return(nil).Once()
697
698 clients := make([]*attachedClient, 2)
699 sends := make([]chan wire.Envelope, 2)
700 for i := range clients {
701 tr, sent := newCapturingTransport(t)
702 clients[i] = &attachedClient{tr: tr, output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 41, Rows: 12}}}
703 clients[i].clientID[0] = byte(i + 1)
704 clients[i].initOverlays()
705 clients[i].setSession(sess)
706 require.True(t, sess.registerAttachment(clients[i]))
707 sends[i] = sent
708 }
709 if tt.name == "non-nil attachment stays targeted" {
710 tt.attachment = clients[0]
711 tt.wantFirst = true
712 }
713
714 require.NoError(t, d.closePane(sess, tb, "pane-2", tt.attachment, true))
715 for i, want := range []bool{tt.wantFirst, tt.wantSecond} {
716 select {
717 case frame := <-sends[i]:
718 require.True(t, want, "attachment %d unexpectedly repainted: %#v", i, frame)
719 require.Equal(t, "Output", envelopeMessageName(t, frame.Payload))
720 default:
721 require.False(t, want, "attachment %d was not repainted", i)
722 }
723 }
724 })
725 }
726}
727
728func TestFinalPaneReapClearsCopyModeForEveryAttachment(t *testing.T) {
729 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
730 tb := testAttachmentTab(sess)
731 other := newTab(nil, domain.Size{Cols: 41, Rows: 10})
732 sess.tabs = append(sess.tabs, other)
733 d.sessions[sess.id] = sess
734 pane := tb.focusedPane()
735 publishTiledPaneOwners(sess, tb)
736 oldPTY.EXPECT().Close().Return(nil).Once()
737
738 for i := range 2 {
739 tr, _ := newCapturingTransport(t)
740 ac := &attachedClient{tr: tr, output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 41, Rows: 12}}}
741 ac.clientID[0] = byte(i + 1)
742 ac.initOverlays()
743 ac.setSession(sess)
744 require.True(t, sess.registerAttachment(ac))
745 ac.overlays.copyMode = &scopy.Mode{}
746 ac.overlays.copyPane = pane
747 }
748
749 d.reapPaneOwner(pane)
750
751 require.Len(t, sess.tabs, 1)
752 for _, ac := range sess.snapshotAttachments() {
753 require.Nil(t, ac.overlays.copyMode, "final-pane reap left copy mode active for attachment %d", ac.clientID[0])
754 require.Nil(t, ac.overlays.copyPane, "final-pane reap left copy pane active for attachment %d", ac.clientID[0])
755 }
756}
757
758func TestClosePaneActionDoesNotReenterDispatchBoundary(t *testing.T) {
759 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
760 tb := testAttachmentTab(sess)
761 closingPTY := portsmocks.NewMockPTY(t)
762 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
763 tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})
764 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
765 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
766 d.sessions[sess.id] = sess
767 oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 41, Rows: 10}}).Return(nil).Once()
768 closingPTY.EXPECT().Close().Return(nil).Once()
769
770 done := make(chan error, 1)
771 go func() {
772 done <- sess.runMutation(func() error {
773 return (daemonActions{d: d}).Run(daemonActionRequest{
774 kind: daemonActionClosePane,
775 target: daemonActionTarget{session: sess, tab: tb, pane: tb.panes["pane-2"]},
776 })
777 })
778 }()
779 select {
780 case err := <-done:
781 require.NoError(t, err)
782 case <-time.After(time.Second):
783 t.Fatal("close pane action re-entered dispatch boundary")
784 }
785}
786
787func TestCloseFocusedPaneRemovesPaneReflowsAndIsIdempotent(t *testing.T) {
788 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
789 tb := testAttachmentTab(sess)
790 closingPTY := portsmocks.NewMockPTY(t)
791 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
792 tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})
793 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
794 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
795 oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 41, Rows: 10}}).Return(nil).Once()
796 closingPTY.EXPECT().Close().Return(nil).Once()
797
798 require.NoError(t, d.closeFocusedPane(sess, nil))
799 require.NoError(t, d.closePane(sess, tb, "pane-2", nil, true))
800
801 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
802 require.Len(t, tb.panes, 1)
803 require.Equal(t, domain.Rect{Width: 41, Height: 10}, tb.panes["pane-1"].rect)
804}
805
806func TestReapPaneSharesClosePathAndIsIdempotent(t *testing.T) {
807 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
808 tb := testAttachmentTab(sess)
809 reapedPTY := portsmocks.NewMockPTY(t)
810 reaped := newPane("pane-2", reapedPTY, domain.Size{Cols: 20, Rows: 10})
811 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
812 tb.panes["pane-2"] = reaped
813 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
814 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
815 oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 41, Rows: 10}}).Return(nil).Once()
816 reapedPTY.EXPECT().Close().Return(nil).Once()
817
818 d.reapPane(sess, tb, reaped)
819 d.reapPane(sess, tb, reaped)
820
821 require.Len(t, tb.panes, 1)
822 require.NotContains(t, tb.panes, layout.PaneID("pane-2"))
823}
824
825func TestClosePaneKeepsTabOpenWhenPendingSpawnLeafExists(t *testing.T) {
826 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
827 tb := testAttachmentTab(sess)
828 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
829 oldPTY.EXPECT().Close().Return(nil).Once()
830
831 require.NoError(t, d.closePane(sess, tb, "pane-1", nil, false))
832
833 require.Len(t, sess.tabs, 1)
834 require.Empty(t, tb.panes)
835 require.True(t, layout.ContainsLeaf(tb.tree.Root, "pane-2"))
836}
837
838func TestCloseFocusedLastPaneDelegatesToCloseTab(t *testing.T) {
839 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
840 otherPTY := portsmocks.NewMockPTY(t)
841 other := newTab(otherPTY, domain.Size{Cols: 41, Rows: 10})
842 sess.tabs = append(sess.tabs, other)
843 d.sessions[sess.id] = sess
844 oldPTY.EXPECT().Close().Return(nil).Once()
845
846 require.NoError(t, d.closeFocusedPane(sess, nil))
847
848 require.Len(t, sess.tabs, 1)
849 require.Same(t, other, sess.tabs[0])
850}
851
852func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) {
853 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5})
854 newPTY := portsmocks.NewMockPTY(t)
855 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
856 factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Geometry{Size: domain.Size{Cols: 20, Rows: 4}}).Return(newPTY, nil).Once()
857
858 require.NoError(t, d.stackPane(sess, nil))
859 tb := testAttachmentTab(sess)
860 require.Equal(t, layout.Stack, tb.tree.Root.Kind)
861 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
862 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded)
863 require.Len(t, tb.panes, 2)
864
865 oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 2}}).Return(nil).Once()
866 newPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 2}}).Return(nil).Once()
867 require.NoError(t, d.toggleStack(sess, nil))
868 require.Equal(t, layout.Split, tb.tree.Root.Kind)
869 require.Equal(t, layout.Vertical, tb.tree.Root.Dir)
870}
871
872func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
873 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3})
874 newPTY := portsmocks.NewMockPTY(t)
875 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
876 factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Geometry{Size: domain.Size{Cols: 20, Rows: 2}}).Return(newPTY, nil).Once()
877 require.NoError(t, d.stackPane(sess, nil))
878 oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 2}}).Return(nil).Once()
879
880 require.NoError(t, d.focusDir(sess, nil, layout.Up, nil))
881
882 tb := testAttachmentTab(sess)
883 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
884 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded)
885 thirdPTY := portsmocks.NewMockPTY(t)
886 factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", mock.Anything).Return(thirdPTY, nil).Maybe()
887 err := d.stackPane(sess, nil)
888 require.ErrorIs(t, err, layout.ErrTooSmall)
889 require.Len(t, tb.panes, 2)
890
891 var ue *domain.UserError
892 require.ErrorAs(t, err, &ue)
893 require.Equal(t, domain.NoticeLayoutTooSmall, ue.Code)
894 require.Equal(t, domain.NoticeWarn, ue.Severity)
895 require.Equal(t, "not enough space to split", ue.Msg)
896 require.NotContains(t, ue.Msg, layout.ErrTooSmall.Error())
897}
898
899func newSplitTestDaemon(t *testing.T, sz domain.Size) (*Daemon, *session, *portsmocks.MockPTY, *portsmocks.MockPTYFactory) {
900 t.Helper()
901 factory := portsmocks.NewMockPTYFactory(t)
902 clock := portsmocks.NewMockClock(t)
903 clock.EXPECT().Now().Return(time.Unix(0, 0)).Maybe()
904 d := New(factory, clock, slog.New(slog.NewTextHandler(io.Discard, nil)), WithShell("/bin/sh", nil))
905 oldPTY := portsmocks.NewMockPTY(t)
906 tb := newTab(oldPTY, sz)
907 tctx, cancel := context.WithCancel(context.Background())
908 t.Cleanup(cancel)
909 tb.ctx, tb.cancel = tctx, cancel
910 for _, p := range tb.panes {
911 p.ctx, p.cancel = tctx, cancel
912 }
913 sess := &session{sessionCore: sessionCore{id: "s", name: "s", incarnation: newTestLifecycle(t)}, cwd: "/work", tabs: []*tab{tb}, ctx: tctx, cancel: cancel}
914 return d, sess, oldPTY, factory
915}
916
917func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
918 writes := make(chan []byte, 1)
919 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
920 newPTY, releaseNew := newBlockingPTYWithWrites(t, writes)
921 defer releaseNew()
922 oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once()
923 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
924 factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(newPTY, nil).Once()
925
926 require.NoError(t, d.splitPane(sess, nil, layout.Right))
927 tb := testAttachmentTab(sess)
928 tb.mu.Lock()
929 tb.tree.Focus = "pane-1"
930 tb.mu.Unlock()
931 oldPTY.EXPECT().Close().Return(nil).Once()
932
933 require.NoError(t, d.closePane(sess, tb, "pane-1", nil, false))
934 require.Equal(t, layout.PaneID("pane-2"), tb.focusedPane().id)
935
936 ac := &attachedClient{}
937 ac.initOverlays()
938 ac.setSession(sess)
939 require.True(t, sess.registerAttachment(ac))
940 daemonKeyHandler{d: d, ac: ac}.Forward([]byte("Z"))
941 require.Equal(t, []byte("Z"), <-writes)
942 tb.focusedPane().screen.Write([]byte("survivor"))
943 content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
944 frame := composeFrame(capturedRenderState{
945 reset: true,
946 layout: capturedTabLayout{area: content, focus: tb.focusedPane().id, valid: true},
947 panes: []capturedPaneRenderState{{
948 id: tb.focusedPane().id, frame: captureTestFrame(tb.focusedPane().screen), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
949 }},
950 bars: barState{status: sess.statusSegments(true)},
951 }, composeCacheInput{}).frame
952 require.Contains(t, frameRowString(frame, 1), "survivor")
953}
954
955func blockingRead(t *testing.T) func([]byte) (int, error) {
956 t.Helper()
957 ch := make(chan struct{})
958 return func([]byte) (int, error) {
959 <-ch
960 return 0, io.EOF
961 }
962}
963
964func TestSplitPaneUsesExactAuthoritativeEnvironment(t *testing.T) {
965 for _, tt := range []struct {
966 name string
967 want []string
968 }{
969 {
970 name: "fixed pane terminal",
971 want: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev"},
972 },
973 } {
974 t.Run(tt.name, func(t *testing.T) {
975 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
976 d.shellOverride = false
977 sess.env = []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}
978 newPTY := portsmocks.NewMockPTY(t)
979 oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once()
980 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
981 var command string
982 var gotEnv []string
983 factory.EXPECT().Open(mock.Anything, mock.Anything, []string(nil), mock.Anything, "/work", domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).RunAndReturn(
984 func(_ context.Context, gotCommand string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
985 command = gotCommand
986 gotEnv = append([]string(nil), env...)
987 return newPTY, nil
988 },
989 ).Once()
990
991 require.NoError(t, d.splitPane(sess, nil, layout.Right))
992 tb := testAttachmentTab(sess)
993 tb.mu.Lock()
994 paneID := tb.panes["pane-2"].stableID
995 tabID := tb.stableID
996 tb.mu.Unlock()
997 require.Equal(t, "/usr/bin/fish", command)
998 require.Equal(t, append(tt.want, "VEV=session=s,tab="+tabID+",pane="+paneID), gotEnv)
999 })
1000 }
1001}