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/domain/layout"
19 "github.com/bnema/vev/internal/ports"
20 portsmocks "github.com/bnema/vev/internal/ports/mocks"
21 "github.com/bnema/vev/internal/protocol"
22 "github.com/bnema/vev/internal/protocol/wire"
23 scopy "github.com/bnema/vev/internal/usecase/copy"
24 themeui "github.com/bnema/vev/internal/usecase/theme"
25)
26
27type gatedFloatingOpen struct {
28 command string
29 args []string
30 env []string
31 dir string
32 size domain.Size
33}
34
35func newGatedOpenFactory(t *testing.T, result ports.PTY, openErr error) (*portsmocks.MockPTYFactory, <-chan gatedFloatingOpen, func()) {
36 t.Helper()
37 factory := portsmocks.NewMockPTYFactory(t)
38 opened := make(chan gatedFloatingOpen, 1)
39 release := make(chan struct{})
40 var releaseOnce sync.Once
41 releaseOpen := func() { releaseOnce.Do(func() { close(release) }) }
42 t.Cleanup(releaseOpen)
43 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
44 func(_ context.Context, command string, args, env []string, dir string, geometry domain.Geometry) (ports.PTY, error) {
45 opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), env: append([]string(nil), env...), dir: dir, size: geometry.Size}
46 <-release
47 return result, openErr
48 }).Once()
49 return factory, opened, releaseOpen
50}
51
52func TestFloatingSlotTransitions(t *testing.T) {
53 first := &pane{}
54 second := &pane{}
55 tests := []struct {
56 name string
57 run func(t *testing.T, tb *tab)
58 }{
59 {
60 name: "warming double toggle retains hidden completion",
61 run: func(t *testing.T, tb *tab) {
62 tb.mu.Lock()
63 generation := tb.beginFloatingWarmLocked(false)
64 start, gotGeneration := tb.toggleFloatingLocked()
65 if start || gotGeneration != generation || !tb.floating.desiredVisible {
66 t.Fatalf("first warming toggle = start %v, generation %d, desired %v", start, gotGeneration, tb.floating.desiredVisible)
67 }
68 start, gotGeneration = tb.toggleFloatingLocked()
69 if start || gotGeneration != generation || tb.floating.desiredVisible {
70 t.Fatalf("second warming toggle = start %v, generation %d, desired %v", start, gotGeneration, tb.floating.desiredVisible)
71 }
72 if !tb.installFloatingLocked(first, generation) || tb.floating.state != floatingHidden || tb.floating.pane != first {
73 t.Fatal("warming completion did not retain a hidden pane")
74 }
75 tb.mu.Unlock()
76 },
77 },
78 {
79 name: "open and hide retain same pane",
80 run: func(t *testing.T, tb *tab) {
81 tb.mu.Lock()
82 generation := tb.beginFloatingWarmLocked(false)
83 if !tb.installFloatingLocked(first, generation) {
84 t.Fatal("install failed")
85 }
86 start, _ := tb.toggleFloatingLocked()
87 if start || tb.floating.state != floatingVisible || tb.floating.pane != first {
88 t.Fatal("hidden open did not retain pane")
89 }
90 tb.toggleFloatingLocked()
91 if tb.floating.state != floatingHidden || tb.floating.pane != first {
92 t.Fatal("visible hide did not retain pane")
93 }
94 tb.mu.Unlock()
95 },
96 },
97 {
98 name: "current launch failure clears desired visibility",
99 run: func(t *testing.T, tb *tab) {
100 tb.mu.Lock()
101 generation := tb.beginFloatingWarmLocked(true)
102 if !tb.failFloatingLocked(generation) || tb.floating.state != floatingUninitialized || tb.floating.desiredVisible || tb.floating.pane != nil {
103 t.Fatal("current launch failure did not clear slot")
104 }
105 tb.mu.Unlock()
106 },
107 },
108 {
109 name: "current exit clears and stale completion is ignored",
110 run: func(t *testing.T, tb *tab) {
111 tb.mu.Lock()
112 generation := tb.beginFloatingWarmLocked(true)
113 if !tb.installFloatingLocked(first, generation) {
114 t.Fatal("install failed")
115 }
116 if !tb.clearFloatingLocked(first, generation) || tb.floating.state != floatingUninitialized || tb.floating.pane != nil {
117 t.Fatal("current exit did not clear slot")
118 }
119 newGeneration := tb.beginFloatingWarmLocked(true)
120 if tb.installFloatingLocked(second, generation) {
121 t.Fatal("stale completion installed")
122 }
123 if tb.floating.state != floatingWarming || tb.floating.generation != newGeneration || tb.floating.pane != nil {
124 t.Fatal("stale completion changed newer warming state")
125 }
126 tb.mu.Unlock()
127 },
128 },
129 }
130
131 for _, tt := range tests {
132 t.Run(tt.name, func(t *testing.T) { tt.run(t, &tab{}) })
133 }
134}
135
136func TestFloatingTransferabilityRejectsOnlyWarmingLaunch(t *testing.T) {
137 tests := []struct {
138 name string
139 state floatingState
140 want bool
141 }{
142 {name: "uninitialized", state: floatingUninitialized, want: true},
143 {name: "warming", state: floatingWarming, want: false},
144 {name: "hidden installed", state: floatingHidden, want: true},
145 {name: "visible installed", state: floatingVisible, want: true},
146 }
147 for _, tt := range tests {
148 t.Run(tt.name, func(t *testing.T) {
149 tb := &tab{floating: floatingSlot{state: tt.state, generation: 7}}
150 tb.mu.Lock()
151 got := tb.floatingTransferableLocked()
152 tb.mu.Unlock()
153 require.Equal(t, tt.want, got)
154 require.Equal(t, tt.state, tb.floating.state, "validation must not change launch state")
155 require.Equal(t, uint64(7), tb.floating.generation, "validation must preserve retry generation")
156 })
157 }
158}
159
160func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testing.T) {
161 pty := portsmocks.NewMockPTY(t)
162 readerStarted := make(chan struct{})
163 release := make(chan struct{})
164 var releaseOnce sync.Once
165 unblock := func() { releaseOnce.Do(func() { close(release) }) }
166 pty.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
167 close(readerStarted)
168 <-release
169 return 0, context.Canceled
170 }).Once()
171 pty.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Once()
172 factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil)
173 d := newTestDaemon(t, factory, stubClock{})
174 cwd := t.TempDir()
175 tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})
176 tb.ctx, tb.cancel = context.WithCancel(t.Context())
177 sess := &session{sessionCore: sessionCore{name: "work"}, cwd: cwd, 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()}
178 d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})
179 d.startFloating(sess, tb, false)
180 // Open has started while this goroutine owns tab.mu: an external factory
181 // call under that lock would deadlock this channel-controlled test.
182 tb.mu.Lock()
183 var openCall gatedFloatingOpen
184 select {
185 case openCall = <-opened:
186 case <-time.After(time.Second):
187 t.Fatal("floating Open waited for tab.mu")
188 }
189 tb.mu.Unlock()
190 d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "later", Width: 90, Height: 90}})
191 allowOpen()
192 select {
193 case <-readerStarted: // install started exactly one reader after the async Open
194 case <-time.After(time.Second):
195 t.Fatal("floating pane was not installed")
196 }
197 tb.mu.Lock()
198 floating := tb.floating.pane
199 tb.mu.Unlock()
200 require.NotNil(t, floating)
201 require.Equal(t, []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + tb.stableID + ",pane=" + floating.stableID}, openCall.env)
202 assertPaneDefaultColors(t, floating, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)
203 assertPaneColorScheme(t, floating, false)
204 require.Equal(t, "/bin/custom-shell", openCall.command)
205 require.Equal(t, []string{"-lc", "btop --utf"}, openCall.args)
206 require.Equal(t, cwd, openCall.dir)
207 require.Equal(t, domain.Size{Cols: 48, Rows: 18}, openCall.size)
208 d.teardownFloating(tb, nil)
209 unblock()
210 d.sessWg.Wait()
211}
212
213func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) {
214 initial := domain.Size{Cols: 80, Rows: 25}
215 latest := domain.Size{Cols: 79, Rows: 25}
216 cfg := domain.FloatingConfig{Width: 100, Height: 100}
217 latestGeometry := calculateContentFloatingGeometry(tabSize(latest), cfg)
218
219 readerRelease := make(chan struct{})
220 readerStarted := make(chan struct{})
221 resized := make(chan struct{})
222 floatingPTY := portsmocks.NewMockPTY(t)
223 floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
224 close(readerStarted)
225 <-readerRelease
226 return 0, io.EOF
227 }).Once()
228 floatingPTY.EXPECT().Resize(domain.Geometry{Size: rectSize(latestGeometry.Inner)}).Run(func(domain.Geometry) { close(resized) }).Return(nil).Once()
229 floatingPTY.EXPECT().Close().RunAndReturn(func() error {
230 select {
231 case <-readerRelease:
232 default:
233 close(readerRelease)
234 }
235 return nil
236 }).Once()
237 factory, opened, allowOpen := newGatedOpenFactory(t, floatingPTY, nil)
238 d := newTestDaemon(t, factory, stubClock{})
239 d.ApplyConfig(domain.Config{Floating: cfg})
240 tabCtx, cancelTab := context.WithCancel(t.Context())
241 t.Cleanup(cancelTab)
242 tb := newTab(&transactionalResizePTY{}, tabSize(initial))
243 tb.ctx, tb.cancel = tabCtx, cancelTab
244 sess := &session{sessionCore: sessionCore{id: "responsive-open", name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
245
246 d.startFloating(sess, tb, true)
247 openCall := <-opened
248 require.Equal(t, rectSize(calculateContentFloatingGeometry(tabSize(initial), cfg).Inner), openCall.size)
249 require.True(t, sess.geometry.requestResize(d, sess, nil, latest, true))
250 allowOpen()
251 select {
252 case <-resized:
253 case <-time.After(time.Second):
254 t.Fatal("floating install did not reconcile the resize committed while Open was blocked")
255 }
256 select {
257 case <-readerStarted:
258 case <-time.After(time.Second):
259 t.Fatal("floating reader did not start")
260 }
261
262 require.Eventually(t, func() bool {
263 tb.mu.Lock()
264 p := tb.floating.pane
265 visible := tb.floating.state == floatingVisible
266 tb.mu.Unlock()
267 if !visible || p == nil {
268 return false
269 }
270 p.mu.Lock()
271 defer p.mu.Unlock()
272 return p.popupGeometry == latestGeometry && p.rect == latestGeometry.Inner
273 }, time.Second, time.Millisecond, "floating install retained the stale Open geometry")
274 d.teardownFloating(tb, nil)
275 d.sessWg.Wait()
276}
277
278func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) {
279 floatingPTY := portsmocks.NewMockPTY(t)
280 readerStarted := make(chan struct{})
281 readerRelease := make(chan struct{})
282 var releaseOnce sync.Once
283 releaseReader := func() { releaseOnce.Do(func() { close(readerRelease) }) }
284 var closeCount int
285 floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
286 close(readerStarted)
287 <-readerRelease
288 return 0, io.EOF
289 }).Once()
290 floatingPTY.EXPECT().Close().RunAndReturn(func() error {
291 closeCount++
292 releaseReader()
293 return nil
294 }).Once()
295 factory, opened, releaseOpen := newGatedOpenFactory(t, floatingPTY, nil)
296 d := newTestDaemon(t, factory, stubClock{})
297 tb := newFloatingTestTab(t)
298 sessCtx, cancel := context.WithCancel(t.Context())
299 sess := &session{sessionCore: sessionCore{id: "floating-ownership", name: "work"}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancel}
300 d.mu.Lock()
301 d.sessions[sess.id] = sess
302 d.mu.Unlock()
303
304 // Keep a known worker alive while the waiter starts. Releasing it after
305 // Open is blocked deterministically exposes an uncounted launch worker.
306 d.sessWg.Add(1)
307 joined := waitGroupDone(&d.sessWg)
308 d.startFloating(sess, tb, true)
309 select {
310 case <-opened:
311 case <-time.After(time.Second):
312 t.Fatal("floating Open did not start")
313 }
314 d.sessWg.Done()
315 // Waiting while Open is blocked must retain the launch worker. Otherwise
316 // daemon shutdown can finish and a late install can Add reader goroutines.
317 launchUnaccounted := false
318 select {
319 case <-joined:
320 launchUnaccounted = true
321 case <-time.After(time.Second):
322 }
323 releaseOpen()
324 select {
325 case <-readerStarted:
326 case <-time.After(time.Second):
327 t.Fatal("floating reader did not start")
328 }
329 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, false))
330 select {
331 case <-waitGroupDone(&d.sessWg):
332 case <-time.After(time.Second):
333 t.Fatal("floating launch, reader, and scheduler did not join")
334 }
335 tb.mu.Lock()
336 require.Nil(t, tb.floating.pane, "shutdown leaked an installed floating pane")
337 require.Equal(t, floatingUninitialized, tb.floating.state)
338 tb.mu.Unlock()
339 require.False(t, launchUnaccounted, "blocked floating launch was not accounted by sessWg")
340 require.Equal(t, 1, closeCount, "floating PTY must close exactly once")
341}
342
343func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *testing.T) {
344 pty := portsmocks.NewMockPTY(t)
345 closed := make(chan struct{})
346 pty.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once()
347 factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil)
348 d := newTestDaemon(t, factory, stubClock{})
349 tb := newFloatingTestTab(t)
350 sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
351 d.startFloating(sess, tb, false)
352 <-opened
353 d.teardownFloating(tb, nil) // invalidate before the delayed Open returns
354 allowOpen()
355 select {
356 case <-closed:
357 case <-time.After(time.Second):
358 t.Fatal("stale completion did not close its PTY")
359 }
360 d.sessWg.Wait()
361
362 first := &pane{}
363 second := &pane{}
364 tb.mu.Lock()
365 g1 := tb.beginFloatingWarmLocked(true)
366 publishPaneOwner(first, sess, tb, g1)
367 require.True(t, tb.installFloatingLocked(first, g1))
368 clearSucceeded := tb.clearFloatingLocked(first, g1)
369 require.True(t, clearSucceeded)
370 generation := tb.beginFloatingWarmLocked(true)
371 require.True(t, tb.installFloatingLocked(second, generation))
372 tb.mu.Unlock()
373 d.reapInstalledFloating(first)
374 tb.mu.Lock()
375 require.Same(t, second, tb.floating.pane, "old EOF must not clear replacement")
376 tb.mu.Unlock()
377}
378
379func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) {
380 factory := &lifetimePTYFactory{}
381 d := newTestDaemon(t, factory, stubClock{})
382 tb := newFloatingTestTab(t)
383 source := &session{sessionCore: sessionCore{id: "source", name: "source"}, tabs: []*tab{tb}, ctx: t.Context()}
384 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, tabs: []*tab{tb}, ctx: t.Context()}
385
386 tb.mu.Lock()
387 generation := tb.beginFloatingWarmLocked(false)
388 tb.mu.Unlock()
389 d.openAndInstallFloating(source, tb, floatingLaunchSpec{
390 sessionName: source.name,
391 size: domain.Size{Cols: 20, Rows: 8},
392 geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
393 paneStableID: "p_floating",
394 parentCtx: tb.ctx,
395 }, generation)
396
397 tb.mu.Lock()
398 floating := tb.floating.pane
399 tb.mu.Unlock()
400 require.NotNil(t, floating)
401 tb.mu.Lock()
402 floating.mu.Lock()
403 floating.publishOwnerLocked(destination, tb, generation)
404 floating.mu.Unlock()
405 tb.mu.Unlock()
406
407 sourceClient := &attachedClient{render: attachmentRenderState{panes: map[*pane]capturedPaneRenderState{floating: {}}}}
408 sourceClient.initOverlays()
409 destinationClient := &attachedClient{render: attachmentRenderState{panes: map[*pane]capturedPaneRenderState{floating: {}}}}
410 destinationClient.initOverlays()
411 source.registerAttachment(sourceClient)
412 destination.registerAttachment(destinationClient)
413
414 floating.onExit()
415 d.sessWg.Wait()
416
417 tb.mu.Lock()
418 require.Equal(t, floatingUninitialized, tb.floating.state)
419 require.Nil(t, tb.floating.pane)
420 tb.mu.Unlock()
421 sourceClient.sendMu.Lock()
422 _, sourceRetained := sourceClient.render.panes[floating]
423 sourceClient.sendMu.Unlock()
424 destinationClient.sendMu.Lock()
425 _, destinationRetained := destinationClient.render.panes[floating]
426 destinationClient.sendMu.Unlock()
427 require.True(t, sourceRetained, "exit must not clean up the retired source owner")
428 require.False(t, destinationRetained, "exit must clean up the current destination owner")
429}
430
431func TestFloatingReapAndTeardownCloseMatchingPaneOnce(t *testing.T) {
432 pty, _ := newBlockingPTY(t)
433 tb := newFloatingTestTab(t)
434 p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})
435 sess := &session{}
436 tb.mu.Lock()
437 g := tb.beginFloatingWarmLocked(true)
438 publishPaneOwner(p, sess, tb, g)
439 require.True(t, tb.installFloatingLocked(p, g))
440 tb.mu.Unlock()
441 d := newTestDaemon(t, nil, stubClock{})
442 d.reapInstalledFloating(p)
443 d.teardownFloating(tb, nil)
444 pty.AssertNumberOfCalls(t, "Close", 1)
445}
446
447func TestFloatingTeardownClosesInstalledPaneOnce(t *testing.T) {
448 pty, _ := newBlockingPTY(t)
449 tb := newFloatingTestTab(t)
450 p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})
451 tb.mu.Lock()
452 g := tb.beginFloatingWarmLocked(false)
453 require.True(t, tb.installFloatingLocked(p, g))
454 tb.mu.Unlock()
455 d := newTestDaemon(t, nil, stubClock{})
456 d.teardownFloating(tb, nil)
457 d.teardownFloating(tb, nil)
458 pty.AssertNumberOfCalls(t, "Close", 1)
459}
460
461func TestFloatingTeardownClearsCapturedCopyMode(t *testing.T) {
462 pty, _ := newBlockingPTY(t)
463 tb := newFloatingTestTab(t)
464 p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})
465 tb.mu.Lock()
466 generation := tb.beginFloatingWarmLocked(true)
467 require.True(t, tb.installFloatingLocked(p, generation))
468 tb.mu.Unlock()
469 ac := &attachedClient{}
470 ac.initOverlays()
471 ac.overlays.copyMu.Lock()
472 ac.overlays.copyMode = &scopy.Mode{}
473 ac.overlays.copyPane = p
474 ac.overlays.copyMu.Unlock()
475 d := newTestDaemon(t, nil, stubClock{})
476
477 d.teardownFloating(tb, ac)
478
479 require.False(t, ac.overlays.copyActive())
480 ac.overlays.copyMu.Lock()
481 require.Nil(t, ac.overlays.copyPane)
482 ac.overlays.copyMu.Unlock()
483}
484
485func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
486 newCase := func(t *testing.T, state floatingState) (*Daemon, *session, *attachedClient, chan wire.Envelope, *tab, *pane, func()) {
487 t.Helper()
488 normalPTY, releaseNormal := newBlockingPTY(t)
489 d, sess, ac, sends := newManualSessionWithPTYs(t, normalPTY)
490 tb := testAttachmentTab(sess)
491 require.NotNil(t, tb)
492 tb.mu.Lock()
493 normal := tb.focusedPane()
494 normal.mu.Lock()
495 normal.screen.Write([]byte("underlying-cell"))
496 normal.mu.Unlock()
497
498 floatingPTY := portsmocks.NewMockPTY(t)
499 floatingPTY.EXPECT().Close().Return(nil).Maybe()
500 releaseEOF := make(chan struct{})
501 floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
502 <-releaseEOF
503 return 0, io.EOF
504 }).Once()
505 floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8})
506 generation := tb.beginFloatingWarmLocked(state == floatingVisible)
507 publishPaneOwner(floating, sess, tb, generation)
508 require.True(t, tb.installFloatingLocked(floating, generation))
509 if state == floatingHidden {
510 require.Equal(t, floatingHidden, tb.floating.state)
511 }
512 tb.mu.Unlock()
513
514 // Establish a renderer shadow first. The EOF repaint must reset it and
515 // redraw the underlying cell rather than depending on popup damage.
516 d.paint(sess, ac, true, nil)
517 baseline := awaitFrame(t, sends, "Output")
518 baselineOutput := unmarshalTestOutput(t, baseline.Payload)
519 require.Contains(t, string(baselineOutput.Data), "underlying-cell")
520
521 reaped := make(chan struct{})
522 floating.onExit = func() {
523 d.reapInstalledFloating(floating)
524 close(reaped)
525 }
526 d.sessWg.Add(1)
527 go d.ptyReader(sess, tb, floating)
528 return d, sess, ac, sends, tb, floating, func() {
529 close(releaseEOF)
530 <-reaped
531 d.sessWg.Wait()
532 releaseNormal()
533 }
534 }
535
536 t.Run("visible matching EOF clears and fully repaints underlying content", func(t *testing.T) {
537 _, _, _, sends, tb, floating, exit := newCase(t, floatingVisible)
538 exit()
539
540 tb.mu.Lock()
541 require.Equal(t, floatingUninitialized, tb.floating.state)
542 require.Nil(t, tb.floating.pane)
543 tb.mu.Unlock()
544 repaint := awaitFrame(t, sends, "Output")
545 output := unmarshalTestOutput(t, repaint.Payload)
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.Geometry) (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 WithCwdReader(func(int) (string, error) { return tt.liveCwd, nil })(d)
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(), geometry: domain.Geometry{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}