1package daemon
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "log/slog"
9 "strings"
10 "sync"
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 scopy "github.com/bnema/vev/internal/usecase/copy"
21 "github.com/bnema/vev/internal/usecase/layout"
22 themeui "github.com/bnema/vev/internal/usecase/theme"
23)
24
25type gatedFloatingOpen struct {
26 command string
27 args []string
28 env []string
29 dir string
30 size domain.Size
31}
32
33func newGatedOpenFactory(t *testing.T, result ports.PTY, openErr error) (*portsmocks.MockPTYFactory, <-chan gatedFloatingOpen, func()) {
34 t.Helper()
35 factory := portsmocks.NewMockPTYFactory(t)
36 opened := make(chan gatedFloatingOpen, 1)
37 release := make(chan struct{})
38 var releaseOnce sync.Once
39 releaseOpen := func() { releaseOnce.Do(func() { close(release) }) }
40 t.Cleanup(releaseOpen)
41 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
42 func(_ context.Context, command string, args, env []string, dir string, size domain.Size) (ports.PTY, error) {
43 opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), env: append([]string(nil), env...), dir: dir, size: size}
44 <-release
45 return result, openErr
46 }).Once()
47 return factory, opened, releaseOpen
48}
49
50func TestFloatingSlotTransitions(t *testing.T) {
51 first := &pane{}
52 second := &pane{}
53 tests := []struct {
54 name string
55 run func(t *testing.T, tb *tab)
56 }{
57 {
58 name: "warming double toggle retains hidden completion",
59 run: func(t *testing.T, tb *tab) {
60 tb.mu.Lock()
61 generation := tb.beginFloatingWarmLocked(false)
62 start, gotGeneration := tb.toggleFloatingLocked()
63 if start || gotGeneration != generation || !tb.floating.desiredVisible {
64 t.Fatalf("first warming toggle = start %v, generation %d, desired %v", start, gotGeneration, tb.floating.desiredVisible)
65 }
66 start, gotGeneration = tb.toggleFloatingLocked()
67 if start || gotGeneration != generation || tb.floating.desiredVisible {
68 t.Fatalf("second warming toggle = start %v, generation %d, desired %v", start, gotGeneration, tb.floating.desiredVisible)
69 }
70 if !tb.installFloatingLocked(first, generation) || tb.floating.state != floatingHidden || tb.floating.pane != first {
71 t.Fatal("warming completion did not retain a hidden pane")
72 }
73 tb.mu.Unlock()
74 },
75 },
76 {
77 name: "open and hide retain same pane",
78 run: func(t *testing.T, tb *tab) {
79 tb.mu.Lock()
80 generation := tb.beginFloatingWarmLocked(false)
81 if !tb.installFloatingLocked(first, generation) {
82 t.Fatal("install failed")
83 }
84 start, _ := tb.toggleFloatingLocked()
85 if start || tb.floating.state != floatingVisible || tb.floating.pane != first {
86 t.Fatal("hidden open did not retain pane")
87 }
88 tb.toggleFloatingLocked()
89 if tb.floating.state != floatingHidden || tb.floating.pane != first {
90 t.Fatal("visible hide did not retain pane")
91 }
92 tb.mu.Unlock()
93 },
94 },
95 {
96 name: "current launch failure clears desired visibility",
97 run: func(t *testing.T, tb *tab) {
98 tb.mu.Lock()
99 generation := tb.beginFloatingWarmLocked(true)
100 if !tb.failFloatingLocked(generation) || tb.floating.state != floatingUninitialized || tb.floating.desiredVisible || tb.floating.pane != nil {
101 t.Fatal("current launch failure did not clear slot")
102 }
103 tb.mu.Unlock()
104 },
105 },
106 {
107 name: "current exit clears and stale completion is ignored",
108 run: func(t *testing.T, tb *tab) {
109 tb.mu.Lock()
110 generation := tb.beginFloatingWarmLocked(true)
111 if !tb.installFloatingLocked(first, generation) {
112 t.Fatal("install failed")
113 }
114 if !tb.clearFloatingLocked(first, generation) || tb.floating.state != floatingUninitialized || tb.floating.pane != nil {
115 t.Fatal("current exit did not clear slot")
116 }
117 newGeneration := tb.beginFloatingWarmLocked(true)
118 if tb.installFloatingLocked(second, generation) {
119 t.Fatal("stale completion installed")
120 }
121 if tb.floating.state != floatingWarming || tb.floating.generation != newGeneration || tb.floating.pane != nil {
122 t.Fatal("stale completion changed newer warming state")
123 }
124 tb.mu.Unlock()
125 },
126 },
127 }
128
129 for _, tt := range tests {
130 t.Run(tt.name, func(t *testing.T) { tt.run(t, &tab{}) })
131 }
132}
133
134func TestFloatingTransferabilityRejectsOnlyWarmingLaunch(t *testing.T) {
135 tests := []struct {
136 name string
137 state floatingState
138 want bool
139 }{
140 {name: "uninitialized", state: floatingUninitialized, want: true},
141 {name: "warming", state: floatingWarming, want: false},
142 {name: "hidden installed", state: floatingHidden, want: true},
143 {name: "visible installed", state: floatingVisible, want: true},
144 }
145 for _, tt := range tests {
146 t.Run(tt.name, func(t *testing.T) {
147 tb := &tab{floating: floatingSlot{state: tt.state, generation: 7}}
148 tb.mu.Lock()
149 got := tb.floatingTransferableLocked()
150 tb.mu.Unlock()
151 require.Equal(t, tt.want, got)
152 require.Equal(t, tt.state, tb.floating.state, "validation must not change launch state")
153 require.Equal(t, uint64(7), tb.floating.generation, "validation must preserve retry generation")
154 })
155 }
156}
157
158func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testing.T) {
159 pty := portsmocks.NewMockPTY(t)
160 readerStarted := make(chan struct{})
161 release := make(chan struct{})
162 var releaseOnce sync.Once
163 unblock := func() { releaseOnce.Do(func() { close(release) }) }
164 pty.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
165 close(readerStarted)
166 <-release
167 return 0, context.Canceled
168 }).Once()
169 pty.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Once()
170 factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil)
171 d := newTestDaemon(t, factory, stubClock{})
172 cwd := t.TempDir()
173 tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})
174 tb.ctx, tb.cancel = context.WithCancel(t.Context())
175 sess := &session{sessionCore: sessionCore{name: "work"}, cwd: cwd, terminal: terminalEnv{TrueColor: true}, env: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}, tabs: []*tab{tb}, ctx: t.Context()}
176 d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})
177 d.ensureFloatingWarm(sess, tb)
178 // Open has started while this goroutine owns tab.mu: an external factory
179 // call under that lock would deadlock this channel-controlled test.
180 tb.mu.Lock()
181 var openCall gatedFloatingOpen
182 select {
183 case openCall = <-opened:
184 case <-time.After(time.Second):
185 t.Fatal("floating Open waited for tab.mu")
186 }
187 tb.mu.Unlock()
188 d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "later", Width: 90, Height: 90}})
189 allowOpen()
190 select {
191 case <-readerStarted: // install started exactly one reader after the async Open
192 case <-time.After(time.Second):
193 t.Fatal("floating pane was not installed")
194 }
195 tb.mu.Lock()
196 floating := tb.floating.pane
197 tb.mu.Unlock()
198 require.NotNil(t, floating)
199 require.Equal(t, []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + tb.stableID + ",pane=" + floating.stableID}, openCall.env)
200 assertPaneDefaultColors(t, floating, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)
201 assertPaneColorScheme(t, floating, false)
202 require.Equal(t, "/bin/custom-shell", openCall.command)
203 require.Equal(t, []string{"-lc", "btop --utf"}, openCall.args)
204 require.Equal(t, cwd, openCall.dir)
205 require.Equal(t, domain.Size{Cols: 48, Rows: 18}, openCall.size)
206 d.teardownFloating(tb, nil)
207 unblock()
208 d.sessWg.Wait()
209}
210
211func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) {
212 initial := domain.Size{Cols: 80, Rows: 25}
213 latest := domain.Size{Cols: 79, Rows: 25}
214 cfg := domain.FloatingConfig{Width: 100, Height: 100}
215 latestGeometry := calculateContentFloatingGeometry(tabSize(latest), cfg)
216
217 readerRelease := make(chan struct{})
218 readerStarted := make(chan struct{})
219 resized := make(chan struct{})
220 floatingPTY := portsmocks.NewMockPTY(t)
221 floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
222 close(readerStarted)
223 <-readerRelease
224 return 0, io.EOF
225 }).Once()
226 floatingPTY.EXPECT().Resize(rectSize(latestGeometry.Inner)).Run(func(domain.Size) { close(resized) }).Return(nil).Once()
227 floatingPTY.EXPECT().Close().RunAndReturn(func() error {
228 select {
229 case <-readerRelease:
230 default:
231 close(readerRelease)
232 }
233 return nil
234 }).Once()
235 factory, opened, allowOpen := newGatedOpenFactory(t, floatingPTY, nil)
236 d := newTestDaemon(t, factory, stubClock{})
237 d.ApplyConfig(domain.Config{Floating: cfg})
238 tabCtx, cancelTab := context.WithCancel(t.Context())
239 t.Cleanup(cancelTab)
240 tb := newTab(&transactionalResizePTY{}, tabSize(initial))
241 tb.ctx, tb.cancel = tabCtx, cancelTab
242 sess := &session{sessionCore: sessionCore{id: "responsive-open", name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
243
244 d.startFloating(sess, tb, true)
245 openCall := <-opened
246 require.Equal(t, rectSize(calculateContentFloatingGeometry(tabSize(initial), cfg).Inner), openCall.size)
247 require.True(t, d.requestTransactionalResize(sess, nil, latest, true))
248 allowOpen()
249 select {
250 case <-resized:
251 case <-time.After(time.Second):
252 t.Fatal("floating install did not reconcile the resize committed while Open was blocked")
253 }
254 select {
255 case <-readerStarted:
256 case <-time.After(time.Second):
257 t.Fatal("floating reader did not start")
258 }
259
260 require.Eventually(t, func() bool {
261 tb.mu.Lock()
262 p := tb.floating.pane
263 visible := tb.floating.state == floatingVisible
264 tb.mu.Unlock()
265 if !visible || p == nil {
266 return false
267 }
268 p.mu.Lock()
269 defer p.mu.Unlock()
270 return p.popupGeometry == latestGeometry && p.rect == latestGeometry.Inner
271 }, time.Second, time.Millisecond, "floating install retained the stale Open geometry")
272 d.teardownFloating(tb, nil)
273 d.sessWg.Wait()
274}
275
276func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) {
277 floatingPTY := portsmocks.NewMockPTY(t)
278 readerStarted := make(chan struct{})
279 readerRelease := make(chan struct{})
280 var releaseOnce sync.Once
281 releaseReader := func() { releaseOnce.Do(func() { close(readerRelease) }) }
282 var closeCount int
283 floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
284 close(readerStarted)
285 <-readerRelease
286 return 0, io.EOF
287 }).Once()
288 floatingPTY.EXPECT().Close().RunAndReturn(func() error {
289 closeCount++
290 releaseReader()
291 return nil
292 }).Once()
293 factory, opened, releaseOpen := newGatedOpenFactory(t, floatingPTY, nil)
294 d := newTestDaemon(t, factory, stubClock{})
295 tb := newFloatingTestTab(t)
296 sessCtx, cancel := context.WithCancel(t.Context())
297 sess := &session{sessionCore: sessionCore{id: "floating-ownership", name: "work"}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancel}
298 d.mu.Lock()
299 d.sessions[sess.id] = sess
300 d.mu.Unlock()
301
302 // Keep a known worker alive while the waiter starts. Releasing it after
303 // Open is blocked deterministically exposes an uncounted launch worker.
304 d.sessWg.Add(1)
305 joined := waitGroupDone(&d.sessWg)
306 d.startFloating(sess, tb, true)
307 select {
308 case <-opened:
309 case <-time.After(time.Second):
310 t.Fatal("floating Open did not start")
311 }
312 d.sessWg.Done()
313 // Waiting while Open is blocked must retain the launch worker. Otherwise
314 // daemon shutdown can finish and a late install can Add reader goroutines.
315 launchUnaccounted := false
316 select {
317 case <-joined:
318 launchUnaccounted = true
319 case <-time.After(time.Second):
320 }
321 releaseOpen()
322 select {
323 case <-readerStarted:
324 case <-time.After(time.Second):
325 t.Fatal("floating reader did not start")
326 }
327 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
328 select {
329 case <-waitGroupDone(&d.sessWg):
330 case <-time.After(time.Second):
331 t.Fatal("floating launch, reader, and scheduler did not join")
332 }
333 tb.mu.Lock()
334 require.Nil(t, tb.floating.pane, "shutdown leaked an installed floating pane")
335 require.Equal(t, floatingUninitialized, tb.floating.state)
336 tb.mu.Unlock()
337 require.False(t, launchUnaccounted, "blocked floating launch was not accounted by sessWg")
338 require.Equal(t, 1, closeCount, "floating PTY must close exactly once")
339}
340
341func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *testing.T) {
342 pty := portsmocks.NewMockPTY(t)
343 closed := make(chan struct{})
344 pty.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once()
345 factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil)
346 d := newTestDaemon(t, factory, stubClock{})
347 tb := newFloatingTestTab(t)
348 sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
349 d.ensureFloatingWarm(sess, tb)
350 <-opened
351 d.teardownFloating(tb, nil) // invalidate before the delayed Open returns
352 allowOpen()
353 select {
354 case <-closed:
355 case <-time.After(time.Second):
356 t.Fatal("stale completion did not close its PTY")
357 }
358 d.sessWg.Wait()
359
360 first := &pane{}
361 second := &pane{}
362 tb.mu.Lock()
363 g1 := tb.beginFloatingWarmLocked(true)
364 publishPaneOwner(first, sess, tb, g1)
365 require.True(t, tb.installFloatingLocked(first, g1))
366 clearSucceeded := tb.clearFloatingLocked(first, g1)
367 require.True(t, clearSucceeded)
368 generation := tb.beginFloatingWarmLocked(true)
369 require.True(t, tb.installFloatingLocked(second, generation))
370 tb.mu.Unlock()
371 d.reapInstalledFloating(first)
372 tb.mu.Lock()
373 require.Same(t, second, tb.floating.pane, "old EOF must not clear replacement")
374 tb.mu.Unlock()
375}
376
377func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) {
378 factory := &lifetimePTYFactory{}
379 d := newTestDaemon(t, factory, stubClock{})
380 tb := newFloatingTestTab(t)
381 source := &session{sessionCore: sessionCore{id: "source", name: "source"}, tabs: []*tab{tb}, ctx: t.Context()}
382 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, tabs: []*tab{tb}, ctx: t.Context()}
383
384 tb.mu.Lock()
385 generation := tb.beginFloatingWarmLocked(false)
386 tb.mu.Unlock()
387 d.openAndInstallFloating(source, tb, floatingLaunchSpec{
388 sessionName: source.name,
389 size: domain.Size{Cols: 20, Rows: 8},
390 geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
391 paneStableID: "p_floating",
392 parentCtx: tb.ctx,
393 }, generation)
394
395 tb.mu.Lock()
396 floating := tb.floating.pane
397 tb.mu.Unlock()
398 require.NotNil(t, floating)
399 tb.mu.Lock()
400 floating.mu.Lock()
401 floating.publishOwnerLocked(destination, tb, generation)
402 floating.mu.Unlock()
403 tb.mu.Unlock()
404
405 sourceClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}}
406 sourceClient.initOverlays()
407 destinationClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}}
408 destinationClient.initOverlays()
409 source.registerAttachment(sourceClient)
410 destination.registerAttachment(destinationClient)
411
412 floating.onExit()
413 d.sessWg.Wait()
414
415 tb.mu.Lock()
416 require.Equal(t, floatingUninitialized, tb.floating.state)
417 require.Nil(t, tb.floating.pane)
418 tb.mu.Unlock()
419 sourceClient.sendMu.Lock()
420 _, sourceRetained := sourceClient.captureFrames[floating]
421 sourceClient.sendMu.Unlock()
422 destinationClient.sendMu.Lock()
423 _, destinationRetained := destinationClient.captureFrames[floating]
424 destinationClient.sendMu.Unlock()
425 require.True(t, sourceRetained, "exit must not clean up the retired source owner")
426 require.False(t, destinationRetained, "exit must clean up the current destination owner")
427}
428
429func TestFloatingReapAndTeardownCloseMatchingPaneOnce(t *testing.T) {
430 pty, _ := newBlockingPTY(t)
431 tb := newFloatingTestTab(t)
432 p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})
433 sess := &session{}
434 tb.mu.Lock()
435 g := tb.beginFloatingWarmLocked(true)
436 publishPaneOwner(p, sess, tb, g)
437 require.True(t, tb.installFloatingLocked(p, g))
438 tb.mu.Unlock()
439 d := newTestDaemon(t, nil, stubClock{})
440 d.reapInstalledFloating(p)
441 d.teardownFloating(tb, nil)
442 pty.AssertNumberOfCalls(t, "Close", 1)
443}
444
445func TestFloatingTeardownClosesInstalledPaneOnce(t *testing.T) {
446 pty, _ := newBlockingPTY(t)
447 tb := newFloatingTestTab(t)
448 p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})
449 tb.mu.Lock()
450 g := tb.beginFloatingWarmLocked(false)
451 require.True(t, tb.installFloatingLocked(p, g))
452 tb.mu.Unlock()
453 d := newTestDaemon(t, nil, stubClock{})
454 d.teardownFloating(tb, nil)
455 d.teardownFloating(tb, nil)
456 pty.AssertNumberOfCalls(t, "Close", 1)
457}
458
459func TestFloatingTeardownClearsCapturedCopyMode(t *testing.T) {
460 pty, _ := newBlockingPTY(t)
461 tb := newFloatingTestTab(t)
462 p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})
463 tb.mu.Lock()
464 generation := tb.beginFloatingWarmLocked(true)
465 require.True(t, tb.installFloatingLocked(p, generation))
466 tb.mu.Unlock()
467 ac := &attachedClient{}
468 ac.initOverlays()
469 ac.overlays.copyMu.Lock()
470 ac.overlays.copyMode = &scopy.Mode{}
471 ac.overlays.copyPane = p
472 ac.overlays.copyMu.Unlock()
473 d := newTestDaemon(t, nil, stubClock{})
474
475 d.teardownFloating(tb, ac)
476
477 require.False(t, ac.overlays.copyActive())
478 ac.overlays.copyMu.Lock()
479 require.Nil(t, ac.overlays.copyPane)
480 ac.overlays.copyMu.Unlock()
481}
482
483func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
484 newCase := func(t *testing.T, state floatingState) (*Daemon, *session, *attachedClient, chan ports.Frame, *tab, *pane, func()) {
485 t.Helper()
486 normalPTY, releaseNormal := newBlockingPTY(t)
487 d, sess, ac, sends := newManualSessionWithPTYs(t, normalPTY)
488 tb := testAttachmentTab(sess)
489 require.NotNil(t, tb)
490 tb.mu.Lock()
491 normal := tb.focusedPane()
492 normal.mu.Lock()
493 normal.screen.Write([]byte("underlying-cell"))
494 normal.mu.Unlock()
495
496 floatingPTY := portsmocks.NewMockPTY(t)
497 floatingPTY.EXPECT().Close().Return(nil).Maybe()
498 releaseEOF := make(chan struct{})
499 floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
500 <-releaseEOF
501 return 0, io.EOF
502 }).Once()
503 floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8})
504 generation := tb.beginFloatingWarmLocked(state == floatingVisible)
505 publishPaneOwner(floating, sess, tb, generation)
506 require.True(t, tb.installFloatingLocked(floating, generation))
507 if state == floatingHidden {
508 require.Equal(t, floatingHidden, tb.floating.state)
509 }
510 tb.mu.Unlock()
511
512 // Establish a renderer shadow first. The EOF repaint must reset it and
513 // redraw the underlying cell rather than depending on popup damage.
514 d.paint(sess, ac, true, nil)
515 baseline := awaitFrame(t, sends, ports.MsgOutput)
516 baselineOutput, err := ports.UnmarshalOutput(baseline.Payload)
517 require.NoError(t, err)
518 require.Contains(t, string(baselineOutput.Data), "underlying-cell")
519
520 reaped := make(chan struct{})
521 floating.onExit = func() {
522 d.reapInstalledFloating(floating)
523 close(reaped)
524 }
525 d.sessWg.Add(1)
526 go d.ptyReader(sess, tb, floating)
527 return d, sess, ac, sends, tb, floating, func() {
528 close(releaseEOF)
529 <-reaped
530 d.sessWg.Wait()
531 releaseNormal()
532 }
533 }
534
535 t.Run("visible matching EOF clears and fully repaints underlying content", func(t *testing.T) {
536 _, _, _, sends, tb, floating, exit := newCase(t, floatingVisible)
537 exit()
538
539 tb.mu.Lock()
540 require.Equal(t, floatingUninitialized, tb.floating.state)
541 require.Nil(t, tb.floating.pane)
542 tb.mu.Unlock()
543 repaint := awaitFrame(t, sends, ports.MsgOutput)
544 output, err := ports.UnmarshalOutput(repaint.Payload)
545 require.NoError(t, err)
546 require.Zero(t, output.Base, "visible EOF must force a dependency-free full repaint")
547 require.Contains(t, string(output.Data), "underlying-cell", "repaint must restore cells previously covered by the popup")
548 require.NotNil(t, floating)
549 })
550
551 t.Run("hidden matching EOF clears without repaint", func(t *testing.T) {
552 _, _, _, sends, tb, _, exit := newCase(t, floatingHidden)
553 exit()
554
555 tb.mu.Lock()
556 require.Equal(t, floatingUninitialized, tb.floating.state)
557 require.Nil(t, tb.floating.pane)
558 tb.mu.Unlock()
559 select {
560 case frame := <-sends:
561 t.Fatalf("hidden EOF unexpectedly repainted: %#v", frame)
562 default:
563 }
564 })
565
566 t.Run("stale EOF is ignored without repaint", func(t *testing.T) {
567 _, _, _, sends, tb, stale, exit := newCase(t, floatingHidden)
568 tb.mu.Lock()
569 generation := tb.floating.generation
570 require.True(t, tb.clearFloatingLocked(stale, generation))
571 current := newPane(layout.PaneID("replacement"), newBlockingPanePTY(t), domain.Size{Cols: 20, Rows: 8})
572 currentGeneration := tb.beginFloatingWarmLocked(true)
573 require.True(t, tb.installFloatingLocked(current, currentGeneration))
574 tb.mu.Unlock()
575 exit()
576
577 tb.mu.Lock()
578 require.Same(t, current, tb.floating.pane)
579 require.Equal(t, floatingVisible, tb.floating.state)
580 tb.mu.Unlock()
581 select {
582 case frame := <-sends:
583 t.Fatalf("stale EOF unexpectedly repainted: %#v", frame)
584 default:
585 }
586 })
587}
588
589func newFloatingTestTab(t *testing.T) *tab {
590 t.Helper()
591 tb := newTabWithStableID("t_test", "p_test", newBlockingPanePTY(t), domain.Size{Cols: 80, Rows: 24})
592 tb.ctx, tb.cancel = context.WithCancel(t.Context())
593 return tb
594}
595
596func newBlockingPanePTY(t *testing.T) *portsmocks.MockPTY {
597 t.Helper()
598 p, _ := newBlockingPTY(t)
599 return p
600}
601
602func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) {
603 tests := []struct {
604 name string
605 sessionCwd string
606 liveCwd string
607 wantCwd string
608 }{
609 {name: "valid session cwd", sessionCwd: "/valid", wantCwd: "/valid"},
610 {name: "invalid session cwd falls home", sessionCwd: "/invalid", wantCwd: "/home/test"},
611 {name: "live focused cwd", sessionCwd: "/invalid", liveCwd: "/live", wantCwd: "/live"},
612 }
613 for _, tt := range tests {
614 t.Run(tt.name, func(t *testing.T) {
615 factory := portsmocks.NewMockPTYFactory(t)
616 opened := make(chan string, 1)
617 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
618 func(_ context.Context, _ string, _ []string, _ []string, cwd string, _ domain.Size) (ports.PTY, error) {
619 opened <- cwd
620 return nil, io.ErrUnexpectedEOF
621 }).Once()
622 d := newTestDaemon(t, factory, stubClock{})
623 d.dirOrHome = func(cwd string) string {
624 if cwd == "/invalid" {
625 return "/home/test"
626 }
627 return cwd
628 }
629 if tt.liveCwd != "" {
630 d.procCwd = func(int) (string, error) { return tt.liveCwd, nil }
631 }
632 tb := newFloatingTestTab(t)
633 sess := &session{sessionCore: sessionCore{name: "work"}, cwd: tt.sessionCwd, tabs: []*tab{tb}, ctx: t.Context()}
634 d.startFloating(sess, tb, false)
635 select {
636 case got := <-opened:
637 require.Equal(t, tt.wantCwd, got)
638 case <-time.After(time.Second):
639 t.Fatal("floating PTY was not opened")
640 }
641 d.sessWg.Wait()
642 })
643 }
644}
645
646type floatingLogHandler struct {
647 slog.Handler
648 handled chan struct{}
649 mu sync.Mutex
650}
651
652func (h *floatingLogHandler) Handle(ctx context.Context, r slog.Record) error {
653 err := h.Handler.Handle(ctx, r)
654 h.mu.Lock()
655 select {
656 case <-h.handled:
657 default:
658 close(h.handled)
659 }
660 h.mu.Unlock()
661 return err
662}
663
664func TestFloatingLaunchFailureCapturesSessionNameBeforeConcurrentRename(t *testing.T) {
665 factory, opened, release := newGatedOpenFactory(t, nil, io.ErrUnexpectedEOF)
666 var logs bytes.Buffer
667 handled := make(chan struct{})
668 d := newTestDaemon(t, factory, stubClock{})
669 d.log = slog.New(&floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled})
670 tb := newFloatingTestTab(t)
671 sess := &session{sessionCore: sessionCore{name: "captured"}, tabs: []*tab{tb}, ctx: t.Context()}
672 d.startFloating(sess, tb, false)
673 <-opened
674 stopRename := make(chan struct{})
675 renameDone := make(chan struct{})
676 go func() {
677 defer close(renameDone)
678 for {
679 select {
680 case <-stopRename:
681 return
682 default:
683 }
684 sess.mu.Lock()
685 sess.name = "renamed"
686 sess.mu.Unlock()
687 }
688 }()
689 release()
690 select {
691 case <-handled:
692 case <-time.After(time.Second):
693 t.Fatal("floating launch failure was not logged")
694 }
695 close(stopRename)
696 <-renameDone
697 d.sessWg.Wait()
698 require.True(t, strings.Contains(logs.String(), "session=captured"), logs.String())
699}
700
701func TestFloatingInstallLogsSessionNameSafelyDuringRename(t *testing.T) {
702 floatingPTY, _ := newBlockingPTY(t)
703 factory, opened, allowOpen := newGatedOpenFactory(t, floatingPTY, nil)
704 var logs bytes.Buffer
705 handled := make(chan struct{})
706 d := newTestDaemon(t, factory, stubClock{})
707 d.log = slog.New(&floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled})
708 tb := newFloatingTestTab(t)
709 sess := &session{sessionCore: sessionCore{name: "captured"}, tabs: []*tab{tb}, ctx: t.Context()}
710
711 d.startFloating(sess, tb, false)
712 <-opened
713 renamed := make(chan struct{})
714 stopRename := make(chan struct{})
715 renameDone := make(chan struct{})
716 go func() {
717 defer close(renameDone)
718 sess.mu.Lock()
719 sess.name = "renamed"
720 sess.mu.Unlock()
721 close(renamed)
722 for {
723 select {
724 case <-stopRename:
725 return
726 default:
727 }
728 sess.mu.Lock()
729 sess.name = "renamed"
730 sess.mu.Unlock()
731 }
732 }()
733 <-renamed
734 allowOpen()
735 select {
736 case <-handled:
737 case <-time.After(time.Second):
738 t.Fatal("installed floating pane was not logged")
739 }
740 close(stopRename)
741 <-renameDone
742 require.Contains(t, logs.String(), "session=renamed", logs.String())
743
744 d.teardownFloating(tb, nil)
745 d.sessWg.Wait()
746}
747
748// TestFloatingAsyncSpawnFailureToastsOnlyForUserOpen drives a failed
749// asynchronous PTY open through failFloatingLaunch. A user-initiated open
750// must surface a toast; a background prewarm must stay silent (the user
751// never asked for it). The failure completes on a worker goroutine, so the
752// test waits on d.sessWg (which the launch worker joins) before asserting on
753// the test goroutine -- no require inside the worker, no sleeps.
754func TestFloatingAsyncSpawnFailureToastsOnlyForUserOpen(t *testing.T) {
755 tests := []struct {
756 name string
757 userOpen bool
758 }{
759 {name: "user-initiated open toasts", userOpen: true},
760 {name: "background prewarm stays silent", userOpen: false},
761 }
762 for _, tc := range tests {
763 t.Run(tc.name, func(t *testing.T) {
764 spawnErr := errors.New("fork/exec: no such file or directory")
765 factory := portsmocks.NewMockPTYFactory(t)
766 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
767 Return(nil, spawnErr).Once()
768 d := newTestDaemon(t, factory, stubClock{})
769 tb := newFloatingTestTab(t)
770 tr, _ := newCapturingTransport(t)
771 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
772 ac.initOverlays()
773 sess := &session{sessionCore: sessionCore{id: "floating-spawn", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, ctx: t.Context()}
774 ac.setSession(sess)
775
776 d.startFloating(sess, tb, tc.userOpen)
777
778 // The launch worker (registered via d.sessWg.Go in launchFloating)
779 // runs failFloatingLaunch synchronously before returning, so
780 // waiting on the group is a deterministic completion signal.
781 d.sessWg.Wait()
782
783 if tc.userOpen {
784 toasts := awaitToastCount(t, ac, 1)
785 require.Equal(t, domain.NoticeFloatingSpawn, toasts[0].Code)
786 require.Equal(t, "couldn't open floating pane: command failed to start", toasts[0].Message)
787 } else {
788 ns, _ := visibleToasts(ac)
789 require.Empty(t, ns, "background prewarm failure must not toast")
790 }
791 })
792 }
793}
794
795func TestToggleFloatingStructuralErrorsAreUserErrors(t *testing.T) {
796 tests := []struct {
797 name string
798 d *Daemon
799 sess *session
800 wantMsg string
801 cause error
802 }{
803 {"nil session", newTestDaemon(t, nil, stubClock{}), nil, "couldn't open floating pane: no active session", nil},
804 {"no active tab", newTestDaemon(t, nil, stubClock{}), &session{}, "couldn't open floating pane: no active tab", layout.ErrNotFound},
805 }
806 for _, tc := range tests {
807 t.Run(tc.name, func(t *testing.T) {
808 err := tc.d.toggleFloating(tc.sess, nil)
809 require.Error(t, err)
810
811 var ue *domain.UserError
812 require.ErrorAs(t, err, &ue)
813 require.Equal(t, domain.NoticeFloatingSpawn, ue.Code)
814 require.Equal(t, domain.NoticeError, ue.Severity)
815 require.Equal(t, tc.wantMsg, ue.Msg)
816 if tc.cause != nil {
817 require.NotContains(t, ue.Msg, tc.cause.Error())
818 require.ErrorIs(t, ue, tc.cause)
819 }
820 })
821 }
822}
823
824func TestFloatingToggleUsesVisibleTarget(t *testing.T) {
825 d := newTestDaemon(t, nil, stubClock{})
826 tb := newFloatingTestTab(t)
827 sess := &session{tabs: []*tab{tb}, ctx: t.Context()}
828 tb.mu.Lock()
829 g := tb.beginFloatingWarmLocked(false)
830 floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8})
831 require.True(t, tb.installFloatingLocked(floating, g))
832 tb.mu.Unlock()
833 require.NoError(t, d.toggleFloating(sess, nil))
834 tb.mu.Lock()
835 require.Same(t, floating, tb.terminalTargetLocked())
836 tb.mu.Unlock()
837}