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