test(daemon): cover floating pane lifecycle
- commit
- 19cbd1ffaf504f6604d10052fe2abec83fc39671
- author
- brice · · 2026-07-10 00:05 UTC
- committer
- brice · · 2026-07-10 05:40 UTC
- parent
- 8ccec9f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +202 -0
M internal/usecase/daemon/client.go +3 -0
| @@ -398,6 +398,9 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, | |||
| 398 | 398 | d.touchMRU(sess) | |
| 399 | 399 | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | |
| 400 | 400 | d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true) | |
| 401 | + | // A restored inactive tab remains cold; the attached active tab starts its | |
| 402 | + | // independent popup prewarm asynchronously. | |
| 403 | + | d.ensureFloatingWarm(sess, sess.activeTab()) | |
| 401 | 404 | return ac, old | |
| 402 | 405 | } | |
| 403 | 406 | ||
| @@ -398,6 +398,9 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, | |||||
| 398 | d.touchMRU(sess) | 398 | d.touchMRU(sess) | ||
| 399 | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | 399 | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | ||
| 400 | d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true) | 400 | d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true) | ||
| 401 | + | // A restored inactive tab remains cold; the attached active tab starts its | |||
| 402 | + | // independent popup prewarm asynchronously. | |||
| 403 | + | d.ensureFloatingWarm(sess, sess.activeTab()) | |||
| 401 | return ac, old | 404 | return ac, old | ||
| 402 | } | 405 | } | ||
| 403 | 406 | ||||
M internal/usecase/daemon/floating_test.go +149 -0
| @@ -1,9 +1,18 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 4 | 6 | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/mock" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 5 | 11 | ||
| 6 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 15 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 7 | 16 | ) | |
| 8 | 17 | ||
| 9 | 18 | func TestFloatingSlotTransitions(t *testing.T) { | |
| @@ -92,6 +101,146 @@ func TestFloatingSlotTransitions(t *testing.T) { | |||
| 92 | 101 | } | |
| 93 | 102 | } | |
| 94 | 103 | ||
| 104 | + | func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testing.T) { | |
| 105 | + | pty := portsmocks.NewMockPTY(t) | |
| 106 | + | readerStarted := make(chan struct{}) | |
| 107 | + | release := make(chan struct{}) | |
| 108 | + | var releaseOnce sync.Once | |
| 109 | + | unblock := func() { releaseOnce.Do(func() { close(release) }) } | |
| 110 | + | pty.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) { | |
| 111 | + | close(readerStarted) | |
| 112 | + | <-release | |
| 113 | + | return 0, context.Canceled | |
| 114 | + | }).Once() | |
| 115 | + | pty.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Once() | |
| 116 | + | factory := portsmocks.NewMockPTYFactory(t) | |
| 117 | + | opened := make(chan struct{}) | |
| 118 | + | allowOpen := make(chan struct{}) | |
| 119 | + | var gotCommand, gotDir string | |
| 120 | + | var gotArgs []string | |
| 121 | + | var gotSize domain.Size | |
| 122 | + | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 123 | + | func(command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) { | |
| 124 | + | gotCommand, gotArgs, gotDir, gotSize = command, append([]string(nil), args...), dir, size | |
| 125 | + | close(opened) | |
| 126 | + | <-allowOpen | |
| 127 | + | return pty, nil | |
| 128 | + | }).Once() | |
| 129 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 130 | + | d.shell = "/bin/custom-shell" | |
| 131 | + | tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40}) | |
| 132 | + | tb.ctx, tb.cancel = context.WithCancel(t.Context()) | |
| 133 | + | sess := &session{name: "work", cwd: "/captured", tabs: []*tab{tb}, ctx: t.Context()} | |
| 134 | + | d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}}) | |
| 135 | + | d.ensureFloatingWarm(sess, tb) | |
| 136 | + | // Open has started while this goroutine owns tab.mu: an external factory | |
| 137 | + | // call under that lock would deadlock this channel-controlled test. | |
| 138 | + | tb.mu.Lock() | |
| 139 | + | select { | |
| 140 | + | case <-opened: | |
| 141 | + | case <-time.After(time.Second): | |
| 142 | + | t.Fatal("floating Open waited for tab.mu") | |
| 143 | + | } | |
| 144 | + | tb.mu.Unlock() | |
| 145 | + | d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Command: "later", Width: 90, Height: 90}}) | |
| 146 | + | close(allowOpen) | |
| 147 | + | select { | |
| 148 | + | case <-readerStarted: // install started exactly one reader after the async Open | |
| 149 | + | case <-time.After(time.Second): | |
| 150 | + | t.Fatal("floating pane was not installed") | |
| 151 | + | } | |
| 152 | + | require.Equal(t, "/bin/custom-shell", gotCommand) | |
| 153 | + | require.Equal(t, []string{"-lc", "btop --utf"}, gotArgs) | |
| 154 | + | require.Equal(t, "/captured", gotDir) | |
| 155 | + | require.Equal(t, domain.Size{Cols: 48, Rows: 18}, gotSize) | |
| 156 | + | d.teardownFloating(tb) | |
| 157 | + | unblock() | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *testing.T) { | |
| 161 | + | pty := portsmocks.NewMockPTY(t) | |
| 162 | + | closed := make(chan struct{}) | |
| 163 | + | pty.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once() | |
| 164 | + | factory := portsmocks.NewMockPTYFactory(t) | |
| 165 | + | opened := make(chan struct{}) | |
| 166 | + | allowOpen := make(chan struct{}) | |
| 167 | + | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 168 | + | func(string, []string, []string, string, domain.Size) (ports.PTY, error) { | |
| 169 | + | close(opened) | |
| 170 | + | <-allowOpen | |
| 171 | + | return pty, nil | |
| 172 | + | }).Once() | |
| 173 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 174 | + | tb := newFloatingTestTab(t) | |
| 175 | + | sess := &session{name: "work", tabs: []*tab{tb}, ctx: t.Context()} | |
| 176 | + | d.ensureFloatingWarm(sess, tb) | |
| 177 | + | <-opened | |
| 178 | + | d.teardownFloating(tb) // invalidate before the delayed Open returns | |
| 179 | + | close(allowOpen) | |
| 180 | + | select { | |
| 181 | + | case <-closed: | |
| 182 | + | case <-time.After(time.Second): | |
| 183 | + | t.Fatal("stale completion did not close its PTY") | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | first := &pane{} | |
| 187 | + | second := &pane{} | |
| 188 | + | tb.mu.Lock() | |
| 189 | + | g1 := tb.beginFloatingWarmLocked(domain.FloatingConfig{}, true) | |
| 190 | + | require.True(t, tb.installFloatingLocked(first, g1)) | |
| 191 | + | g2 := tb.clearFloatingLocked(first, g1) | |
| 192 | + | require.True(t, g2) | |
| 193 | + | generation := tb.beginFloatingWarmLocked(domain.FloatingConfig{}, true) | |
| 194 | + | require.True(t, tb.installFloatingLocked(second, generation)) | |
| 195 | + | tb.mu.Unlock() | |
| 196 | + | d.reapFloating(sess, tb, first, g1) | |
| 197 | + | tb.mu.Lock() | |
| 198 | + | require.Same(t, second, tb.floating.pane, "old EOF must not clear replacement") | |
| 199 | + | tb.mu.Unlock() | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestFloatingTeardownClosesInstalledPaneOnce(t *testing.T) { | |
| 203 | + | pty, _ := newBlockingPTY(t) | |
| 204 | + | tb := newFloatingTestTab(t) | |
| 205 | + | p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10}) | |
| 206 | + | tb.mu.Lock() | |
| 207 | + | g := tb.beginFloatingWarmLocked(domain.FloatingConfig{}, false) | |
| 208 | + | require.True(t, tb.installFloatingLocked(p, g)) | |
| 209 | + | tb.mu.Unlock() | |
| 210 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 211 | + | d.teardownFloating(tb) | |
| 212 | + | d.teardownFloating(tb) | |
| 213 | + | pty.AssertNumberOfCalls(t, "Close", 1) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func newFloatingTestTab(t *testing.T) *tab { | |
| 217 | + | t.Helper() | |
| 218 | + | tb := newTabWithStableID("t_test", "p_test", newBlockingPanePTY(t), domain.Size{Cols: 80, Rows: 24}) | |
| 219 | + | tb.ctx, tb.cancel = context.WithCancel(t.Context()) | |
| 220 | + | return tb | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func newBlockingPanePTY(t *testing.T) *portsmocks.MockPTY { | |
| 224 | + | t.Helper() | |
| 225 | + | p, _ := newBlockingPTY(t) | |
| 226 | + | return p | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func TestFloatingToggleUsesVisibleTarget(t *testing.T) { | |
| 230 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 231 | + | tb := newFloatingTestTab(t) | |
| 232 | + | sess := &session{tabs: []*tab{tb}, ctx: t.Context()} | |
| 233 | + | tb.mu.Lock() | |
| 234 | + | g := tb.beginFloatingWarmLocked(domain.FloatingConfig{}, false) | |
| 235 | + | floating := &pane{} | |
| 236 | + | require.True(t, tb.installFloatingLocked(floating, g)) | |
| 237 | + | tb.mu.Unlock() | |
| 238 | + | require.NoError(t, d.toggleFloating(sess, nil)) | |
| 239 | + | tb.mu.Lock() | |
| 240 | + | require.Same(t, floating, tb.terminalTargetLocked()) | |
| 241 | + | tb.mu.Unlock() | |
| 242 | + | } | |
| 243 | + | ||
| 95 | 244 | func TestFloatingInnerSize(t *testing.T) { | |
| 96 | 245 | tests := []struct { | |
| 97 | 246 | name string | |
| @@ -1,9 +1,18 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 4 | "testing" | 6 | "testing" | ||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/mock" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 5 | 11 | ||||
| 6 | "github.com/bnema/vev/internal/domain" | 12 | "github.com/bnema/vev/internal/domain" | ||
| 13 | + | "github.com/bnema/vev/internal/ports" | |||
| 14 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 15 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 7 | ) | 16 | ) | ||
| 8 | 17 | ||||
| 9 | func TestFloatingSlotTransitions(t *testing.T) { | 18 | func TestFloatingSlotTransitions(t *testing.T) { | ||
| @@ -92,6 +101,146 @@ func TestFloatingSlotTransitions(t *testing.T) { | |||||
| 92 | } | 101 | } | ||
| 93 | } | 102 | } | ||
| 94 | 103 | ||||
| 104 | + | func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testing.T) { | |||
| 105 | + | pty := portsmocks.NewMockPTY(t) | |||
| 106 | + | readerStarted := make(chan struct{}) | |||
| 107 | + | release := make(chan struct{}) | |||
| 108 | + | var releaseOnce sync.Once | |||
| 109 | + | unblock := func() { releaseOnce.Do(func() { close(release) }) } | |||
| 110 | + | pty.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) { | |||
| 111 | + | close(readerStarted) | |||
| 112 | + | <-release | |||
| 113 | + | return 0, context.Canceled | |||
| 114 | + | }).Once() | |||
| 115 | + | pty.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Once() | |||
| 116 | + | factory := portsmocks.NewMockPTYFactory(t) | |||
| 117 | + | opened := make(chan struct{}) | |||
| 118 | + | allowOpen := make(chan struct{}) | |||
| 119 | + | var gotCommand, gotDir string | |||
| 120 | + | var gotArgs []string | |||
| 121 | + | var gotSize domain.Size | |||
| 122 | + | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 123 | + | func(command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) { | |||
| 124 | + | gotCommand, gotArgs, gotDir, gotSize = command, append([]string(nil), args...), dir, size | |||
| 125 | + | close(opened) | |||
| 126 | + | <-allowOpen | |||
| 127 | + | return pty, nil | |||
| 128 | + | }).Once() | |||
| 129 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 130 | + | d.shell = "/bin/custom-shell" | |||
| 131 | + | tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40}) | |||
| 132 | + | tb.ctx, tb.cancel = context.WithCancel(t.Context()) | |||
| 133 | + | sess := &session{name: "work", cwd: "/captured", tabs: []*tab{tb}, ctx: t.Context()} | |||
| 134 | + | d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}}) | |||
| 135 | + | d.ensureFloatingWarm(sess, tb) | |||
| 136 | + | // Open has started while this goroutine owns tab.mu: an external factory | |||
| 137 | + | // call under that lock would deadlock this channel-controlled test. | |||
| 138 | + | tb.mu.Lock() | |||
| 139 | + | select { | |||
| 140 | + | case <-opened: | |||
| 141 | + | case <-time.After(time.Second): | |||
| 142 | + | t.Fatal("floating Open waited for tab.mu") | |||
| 143 | + | } | |||
| 144 | + | tb.mu.Unlock() | |||
| 145 | + | d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Command: "later", Width: 90, Height: 90}}) | |||
| 146 | + | close(allowOpen) | |||
| 147 | + | select { | |||
| 148 | + | case <-readerStarted: // install started exactly one reader after the async Open | |||
| 149 | + | case <-time.After(time.Second): | |||
| 150 | + | t.Fatal("floating pane was not installed") | |||
| 151 | + | } | |||
| 152 | + | require.Equal(t, "/bin/custom-shell", gotCommand) | |||
| 153 | + | require.Equal(t, []string{"-lc", "btop --utf"}, gotArgs) | |||
| 154 | + | require.Equal(t, "/captured", gotDir) | |||
| 155 | + | require.Equal(t, domain.Size{Cols: 48, Rows: 18}, gotSize) | |||
| 156 | + | d.teardownFloating(tb) | |||
| 157 | + | unblock() | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *testing.T) { | |||
| 161 | + | pty := portsmocks.NewMockPTY(t) | |||
| 162 | + | closed := make(chan struct{}) | |||
| 163 | + | pty.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once() | |||
| 164 | + | factory := portsmocks.NewMockPTYFactory(t) | |||
| 165 | + | opened := make(chan struct{}) | |||
| 166 | + | allowOpen := make(chan struct{}) | |||
| 167 | + | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 168 | + | func(string, []string, []string, string, domain.Size) (ports.PTY, error) { | |||
| 169 | + | close(opened) | |||
| 170 | + | <-allowOpen | |||
| 171 | + | return pty, nil | |||
| 172 | + | }).Once() | |||
| 173 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 174 | + | tb := newFloatingTestTab(t) | |||
| 175 | + | sess := &session{name: "work", tabs: []*tab{tb}, ctx: t.Context()} | |||
| 176 | + | d.ensureFloatingWarm(sess, tb) | |||
| 177 | + | <-opened | |||
| 178 | + | d.teardownFloating(tb) // invalidate before the delayed Open returns | |||
| 179 | + | close(allowOpen) | |||
| 180 | + | select { | |||
| 181 | + | case <-closed: | |||
| 182 | + | case <-time.After(time.Second): | |||
| 183 | + | t.Fatal("stale completion did not close its PTY") | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | first := &pane{} | |||
| 187 | + | second := &pane{} | |||
| 188 | + | tb.mu.Lock() | |||
| 189 | + | g1 := tb.beginFloatingWarmLocked(domain.FloatingConfig{}, true) | |||
| 190 | + | require.True(t, tb.installFloatingLocked(first, g1)) | |||
| 191 | + | g2 := tb.clearFloatingLocked(first, g1) | |||
| 192 | + | require.True(t, g2) | |||
| 193 | + | generation := tb.beginFloatingWarmLocked(domain.FloatingConfig{}, true) | |||
| 194 | + | require.True(t, tb.installFloatingLocked(second, generation)) | |||
| 195 | + | tb.mu.Unlock() | |||
| 196 | + | d.reapFloating(sess, tb, first, g1) | |||
| 197 | + | tb.mu.Lock() | |||
| 198 | + | require.Same(t, second, tb.floating.pane, "old EOF must not clear replacement") | |||
| 199 | + | tb.mu.Unlock() | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func TestFloatingTeardownClosesInstalledPaneOnce(t *testing.T) { | |||
| 203 | + | pty, _ := newBlockingPTY(t) | |||
| 204 | + | tb := newFloatingTestTab(t) | |||
| 205 | + | p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10}) | |||
| 206 | + | tb.mu.Lock() | |||
| 207 | + | g := tb.beginFloatingWarmLocked(domain.FloatingConfig{}, false) | |||
| 208 | + | require.True(t, tb.installFloatingLocked(p, g)) | |||
| 209 | + | tb.mu.Unlock() | |||
| 210 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 211 | + | d.teardownFloating(tb) | |||
| 212 | + | d.teardownFloating(tb) | |||
| 213 | + | pty.AssertNumberOfCalls(t, "Close", 1) | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func newFloatingTestTab(t *testing.T) *tab { | |||
| 217 | + | t.Helper() | |||
| 218 | + | tb := newTabWithStableID("t_test", "p_test", newBlockingPanePTY(t), domain.Size{Cols: 80, Rows: 24}) | |||
| 219 | + | tb.ctx, tb.cancel = context.WithCancel(t.Context()) | |||
| 220 | + | return tb | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | func newBlockingPanePTY(t *testing.T) *portsmocks.MockPTY { | |||
| 224 | + | t.Helper() | |||
| 225 | + | p, _ := newBlockingPTY(t) | |||
| 226 | + | return p | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | func TestFloatingToggleUsesVisibleTarget(t *testing.T) { | |||
| 230 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 231 | + | tb := newFloatingTestTab(t) | |||
| 232 | + | sess := &session{tabs: []*tab{tb}, ctx: t.Context()} | |||
| 233 | + | tb.mu.Lock() | |||
| 234 | + | g := tb.beginFloatingWarmLocked(domain.FloatingConfig{}, false) | |||
| 235 | + | floating := &pane{} | |||
| 236 | + | require.True(t, tb.installFloatingLocked(floating, g)) | |||
| 237 | + | tb.mu.Unlock() | |||
| 238 | + | require.NoError(t, d.toggleFloating(sess, nil)) | |||
| 239 | + | tb.mu.Lock() | |||
| 240 | + | require.Same(t, floating, tb.terminalTargetLocked()) | |||
| 241 | + | tb.mu.Unlock() | |||
| 242 | + | } | |||
| 243 | + | ||||
| 95 | func TestFloatingInnerSize(t *testing.T) { | 244 | func TestFloatingInnerSize(t *testing.T) { | ||
| 96 | tests := []struct { | 245 | tests := []struct { | ||
| 97 | name string | 246 | name string | ||
M internal/usecase/daemon/snapshot_restore_test.go +26 -0
| @@ -105,6 +105,32 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 105 | 105 | require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID) | |
| 106 | 106 | } | |
| 107 | 107 | ||
| 108 | + | func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) { | |
| 109 | + | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 110 | + | Name: "work", Active: 0, Tabs: []snapcodec.Tab{ | |
| 111 | + | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/one"}}}, | |
| 112 | + | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/two"}}}, | |
| 113 | + | }, | |
| 114 | + | })}}} | |
| 115 | + | d := newTestDaemon(t, &restorePTYFactory{}, stubClock{}) | |
| 116 | + | WithSnapshotStore(store)(d) | |
| 117 | + | d.restoreSnapshots(context.Background()) | |
| 118 | + | d.mu.Lock() | |
| 119 | + | restored := d.findByNameLocked("work") | |
| 120 | + | d.mu.Unlock() | |
| 121 | + | require.NotNil(t, restored) | |
| 122 | + | restored.mu.Lock() | |
| 123 | + | tabs := append([]*tab(nil), restored.tabs...) | |
| 124 | + | restored.mu.Unlock() | |
| 125 | + | require.Len(t, tabs, 2) | |
| 126 | + | for _, tb := range tabs { | |
| 127 | + | tb.mu.Lock() | |
| 128 | + | require.Equal(t, floatingUninitialized, tb.floating.state) | |
| 129 | + | require.Nil(t, tb.floating.pane) | |
| 130 | + | tb.mu.Unlock() | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 108 | 134 | func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore { | |
| 109 | 135 | t.Helper() | |
| 110 | 136 | return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| @@ -105,6 +105,32 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||||
| 105 | require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID) | 105 | require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID) | ||
| 106 | } | 106 | } | ||
| 107 | 107 | ||||
| 108 | + | func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) { | |||
| 109 | + | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | |||
| 110 | + | Name: "work", Active: 0, Tabs: []snapcodec.Tab{ | |||
| 111 | + | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/one"}}}, | |||
| 112 | + | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/two"}}}, | |||
| 113 | + | }, | |||
| 114 | + | })}}} | |||
| 115 | + | d := newTestDaemon(t, &restorePTYFactory{}, stubClock{}) | |||
| 116 | + | WithSnapshotStore(store)(d) | |||
| 117 | + | d.restoreSnapshots(context.Background()) | |||
| 118 | + | d.mu.Lock() | |||
| 119 | + | restored := d.findByNameLocked("work") | |||
| 120 | + | d.mu.Unlock() | |||
| 121 | + | require.NotNil(t, restored) | |||
| 122 | + | restored.mu.Lock() | |||
| 123 | + | tabs := append([]*tab(nil), restored.tabs...) | |||
| 124 | + | restored.mu.Unlock() | |||
| 125 | + | require.Len(t, tabs, 2) | |||
| 126 | + | for _, tb := range tabs { | |||
| 127 | + | tb.mu.Lock() | |||
| 128 | + | require.Equal(t, floatingUninitialized, tb.floating.state) | |||
| 129 | + | require.Nil(t, tb.floating.pane) | |||
| 130 | + | tb.mu.Unlock() | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 108 | func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore { | 134 | func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore { | ||
| 109 | t.Helper() | 135 | t.Helper() | ||
| 110 | return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{ | 136 | return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{ | ||
M internal/usecase/daemon/snapshot_test.go +24 -0
| @@ -63,6 +63,30 @@ func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) { | |||
| 63 | 63 | } | |
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | + | func TestSnapshotCaptureExcludesFloatingRuntime(t *testing.T) { | |
| 67 | + | store := portsmocks.NewMockSnapshotStore(t) | |
| 68 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 69 | + | WithSnapshotStore(store)(d) | |
| 70 | + | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 71 | + | tb := sess.tabs[0] | |
| 72 | + | floatingPTY, _ := newBlockingPTY(t) | |
| 73 | + | floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8}) | |
| 74 | + | tb.mu.Lock() | |
| 75 | + | generation := tb.beginFloatingWarmLocked(domain.FloatingConfig{Command: "top"}, false) | |
| 76 | + | require.True(t, tb.installFloatingLocked(floating, generation)) | |
| 77 | + | tb.mu.Unlock() | |
| 78 | + | ||
| 79 | + | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 80 | + | snap, err := snapcodec.Unmarshal(data) | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | require.Len(t, snap.Tabs, 1) | |
| 83 | + | require.Len(t, snap.Tabs[0].Panes, 1, "floating runtime must not be persisted") | |
| 84 | + | require.Equal(t, layout.PaneID("pane-1"), snap.Tabs[0].Panes[0].ID) | |
| 85 | + | return nil | |
| 86 | + | }).Once() | |
| 87 | + | require.True(t, d.captureSession(sess)) | |
| 88 | + | } | |
| 89 | + | ||
| 66 | 90 | func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) { | |
| 67 | 91 | store := portsmocks.NewMockSnapshotStore(t) | |
| 68 | 92 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| @@ -63,6 +63,30 @@ func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) { | |||||
| 63 | } | 63 | } | ||
| 64 | } | 64 | } | ||
| 65 | 65 | ||||
| 66 | + | func TestSnapshotCaptureExcludesFloatingRuntime(t *testing.T) { | |||
| 67 | + | store := portsmocks.NewMockSnapshotStore(t) | |||
| 68 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 69 | + | WithSnapshotStore(store)(d) | |||
| 70 | + | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |||
| 71 | + | tb := sess.tabs[0] | |||
| 72 | + | floatingPTY, _ := newBlockingPTY(t) | |||
| 73 | + | floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8}) | |||
| 74 | + | tb.mu.Lock() | |||
| 75 | + | generation := tb.beginFloatingWarmLocked(domain.FloatingConfig{Command: "top"}, false) | |||
| 76 | + | require.True(t, tb.installFloatingLocked(floating, generation)) | |||
| 77 | + | tb.mu.Unlock() | |||
| 78 | + | ||||
| 79 | + | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |||
| 80 | + | snap, err := snapcodec.Unmarshal(data) | |||
| 81 | + | require.NoError(t, err) | |||
| 82 | + | require.Len(t, snap.Tabs, 1) | |||
| 83 | + | require.Len(t, snap.Tabs[0].Panes, 1, "floating runtime must not be persisted") | |||
| 84 | + | require.Equal(t, layout.PaneID("pane-1"), snap.Tabs[0].Panes[0].ID) | |||
| 85 | + | return nil | |||
| 86 | + | }).Once() | |||
| 87 | + | require.True(t, d.captureSession(sess)) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 66 | func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) { | 90 | func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) { | ||
| 67 | store := portsmocks.NewMockSnapshotStore(t) | 91 | store := portsmocks.NewMockSnapshotStore(t) | ||
| 68 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 92 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | ||