1package client
2
3import (
4 "testing"
5 "time"
6
7 renderer "github.com/bnema/vev-vt"
8 "github.com/stretchr/testify/require"
9
10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"
12 portsmocks "github.com/bnema/vev/internal/ports/mocks"
13 "github.com/bnema/vev/internal/protocol"
14 "github.com/bnema/vev/internal/protocol/catalogue"
15 pickerusecase "github.com/bnema/vev/internal/usecase/picker"
16)
17
18func previewTestSession(name string, seed byte, tabs ...string) catalogue.RemoteCatalogSession {
19 session := pickerTestSession(name, seed, catalogue_Up)
20 for _, tab := range tabs {
21 session.Tabs = append(session.Tabs, catalogue.RemoteCatalogTab{ID: tab, Name: tab})
22 }
23 if len(tabs) > 0 {
24 session.ActiveTabID = tabs[0]
25 }
26 return session
27}
28
29func previewTestController(t *testing.T, observations ...ports.BrokerDaemonObservation) *pickerController {
30 t.Helper()
31 controller, _ := pickerTestController(t)
32 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 7, Revision: 1, Daemons: observations})
33 require.NotEmpty(t, controller.Render(domain.Size{Cols: 100, Rows: 40}))
34 return controller
35}
36
37// previewHarness drives one manager over a mocked broker service with a
38// manual debounce clock.
39type previewHarness struct {
40 t *testing.T
41 controller *pickerController
42 service *portsmocks.MockBrokerService
43 clock *supervisorTestClock
44 manager *previewManager
45 size domain.Size
46 connection ports.BrokerConnectionID
47}
48
49func newPreviewHarness(t *testing.T, observations ...ports.BrokerDaemonObservation) *previewHarness {
50 t.Helper()
51 clock := newSupervisorTestClock()
52 h := &previewHarness{
53 t: t, controller: previewTestController(t, observations...), service: portsmocks.NewMockBrokerService(t),
54 clock: clock, manager: &previewManager{clock: clock}, size: domain.Size{Cols: 100, Rows: 40},
55 connection: ports.BrokerConnectionID{11},
56 }
57 h.service.EXPECT().ConnectionID().Return(h.connection).Maybe()
58 h.service.EXPECT().Snapshot().Return(ports.BrokerSnapshot{Epoch: 7}).Maybe()
59 t.Cleanup(func() { h.manager.stop() })
60 return h
61}
62
63// expected is the broker request the manager must subscribe for the current
64// selection and generation.
65func (h *previewHarness) expected(generation ports.BrokerPreviewGeneration) ports.BrokerPreviewRequest {
66 h.t.Helper()
67 route, preview, ok := h.controller.PreviewRequest(h.size)
68 require.True(h.t, ok, "the selected row must be previewable")
69 return ports.BrokerPreviewRequest{Epoch: 7, Connection: h.connection, Generation: generation, Route: route, Preview: preview}
70}
71
72// settle fires the armed debounce and runs the manager's wake.
73func (h *previewHarness) settle() bool {
74 h.t.Helper()
75 h.clock.awaitTimer(h.t).fire()
76 h.awaitWake()
77 return h.manager.publish(h.controller)
78}
79
80func (h *previewHarness) awaitWake() {
81 h.t.Helper()
82 select {
83 case <-h.manager.changed():
84 case <-time.After(5 * time.Second):
85 h.t.Fatal("the preview manager did not wake")
86 }
87}
88
89func (h *previewHarness) subscribe(generation ports.BrokerPreviewGeneration) (*portsmocks.MockBrokerPreviewSubscription, ports.BrokerPreviewRequest) {
90 h.t.Helper()
91 expected := h.expected(generation)
92 sub := portsmocks.NewMockBrokerPreviewSubscription(h.t)
93 sub.EXPECT().Changed().Return(make(chan struct{})).Maybe()
94 h.service.EXPECT().SubscribePreview(expected).Return(sub, nil).Once()
95 h.manager.refresh(h.service, h.controller, h.size)
96 require.False(h.t, h.settle(), "subscribing alone changes nothing on screen")
97 require.Equal(h.t, expected, h.manager.request)
98 return sub, expected
99}
100
101func (h *previewHarness) shown() previewShown {
102 h.controller.mu.Lock()
103 defer h.controller.mu.Unlock()
104 return previewShownFrom(h.controller.preview)
105}
106
107type previewShown struct {
108 text string
109 dim bool
110}
111
112func previewShownFrom(view pickerusecase.Preview) previewShown {
113 var shown previewShown
114 for _, row := range view.Rows {
115 for _, cell := range row {
116 if cell.Rune != 0 {
117 shown.text += string(cell.Rune)
118 }
119 shown.dim = shown.dim || cell.Style.Attrs&renderer.AttrDim != 0
120 }
121 }
122 return shown
123}
124
125func previewFrame(request ports.BrokerPreviewRequest, text string) protocol.RemotePreview {
126 cells := make([]renderer.Cell, 0, len(text))
127 for _, r := range text {
128 cells = append(cells, renderer.Cell{Rune: r})
129 }
130 return protocol.RemotePreview{
131 Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewOK,
132 LifecycleID: request.Preview.Target.LifecycleID, TabID: request.Preview.Target.LiveTabID,
133 Revision: 1, Width: uint16(len(cells)), Height: 1, Cells: cells,
134 }
135}
136
137func publication(request ports.BrokerPreviewRequest, preview protocol.RemotePreview, err error) ports.BrokerPreviewPublication {
138 return ports.BrokerPreviewPublication{Epoch: request.Epoch, Connection: request.Connection, Generation: request.Generation,
139 Target: request.Preview.Target, Width: request.Preview.Width, Height: request.Preview.Height, Preview: preview, Err: err}
140}
141
142func TestPreviewManagerRequestNamesRouteAndSelectedTab(t *testing.T) {
143 now := time.Unix(100, 0)
144 registration := pickerTestRegistration("ssh://example.test", 2)
145 cases := []struct {
146 name string
147 observations []ports.BrokerDaemonObservation
148 moves string
149 wantRoute ports.BrokerPreviewRoute
150 wantEndpoint string
151 wantTab domain.TabStableID
152 }{
153 {
154 name: "local active tab",
155 observations: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, previewTestSession("local-session", 1, "tab-local"))},
156 wantRoute: ports.BrokerPreviewRoute{Local: true, Policy: pickerTestPolicy()},
157 wantEndpoint: ports.BrokerPreviewLocalEndpoint, wantTab: "tab-local",
158 },
159 {
160 name: "remote active tab",
161 observations: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("ssh://example.test", 2, 3, now, previewTestSession("remote-session", 4, "tab-remote"))},
162 wantRoute: ports.BrokerPreviewRoute{Endpoint: "ssh://example.test", Registration: registration, Policy: pickerTestPolicy()},
163 wantEndpoint: "ssh://example.test", wantTab: "tab-remote",
164 },
165 {
166 name: "selected non-active tab row",
167 observations: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, previewTestSession("work", 1, "tab-1", "tab-2"))},
168 moves: "j",
169 wantRoute: ports.BrokerPreviewRoute{Local: true, Policy: pickerTestPolicy()},
170 wantEndpoint: ports.BrokerPreviewLocalEndpoint, wantTab: "tab-2",
171 },
172 }
173 for _, tc := range cases {
174 t.Run(tc.name, func(t *testing.T) {
175 controller := previewTestController(t, tc.observations...)
176 if tc.moves != "" {
177 require.True(t, controller.ConsumeTerminalRead([]byte(tc.moves)))
178 }
179 size := domain.Size{Cols: 100, Rows: 40}
180 route, preview, ok := controller.PreviewRequest(size)
181 require.True(t, ok)
182 require.Equal(t, tc.wantRoute, route)
183 require.Equal(t, tc.wantEndpoint, preview.Target.Endpoint)
184 require.Equal(t, tc.wantTab, preview.Target.LiveTabID)
185 require.Equal(t, pickerPreviewSize(size), domain.Size{Cols: int(preview.Width), Rows: int(preview.Height)})
186 require.NoError(t, ports.BrokerPreviewRequest{Epoch: 7, Connection: ports.BrokerConnectionID{1}, Generation: 1, Route: route, Preview: preview}.Validate())
187 })
188 }
189}
190
191func TestPreviewManagerDebouncesSelectionOnTheClock(t *testing.T) {
192 now := time.Unix(100, 0)
193 h := newPreviewHarness(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-a"), previewTestSession("beta", 2, "tab-b")))
194
195 h.manager.refresh(h.service, h.controller, h.size)
196 first := h.clock.awaitTimer(t)
197 require.Equal(t, pickerPreviewDebounce, first.delay)
198 require.Equal(t, previewShown{text: "loading preview…", dim: true}, h.shown(), "a miss shows loading at once")
199
200 // Moving before the debounce elapses replaces it: nothing subscribes for
201 // the row the cursor only passed over.
202 require.True(t, h.controller.ConsumeTerminalRead([]byte("j")))
203 h.manager.refresh(h.service, h.controller, h.size)
204 require.Eventually(t, first.stopped, 5*time.Second, time.Millisecond, "the superseded debounce stops its timer")
205
206 expected := h.expected(1)
207 require.Equal(t, domain.TabStableID("tab-b"), expected.Preview.Target.LiveTabID)
208 sub := portsmocks.NewMockBrokerPreviewSubscription(t)
209 h.service.EXPECT().SubscribePreview(expected).Return(sub, nil).Once()
210 require.False(t, h.settle())
211 sub.EXPECT().Close().Once()
212 h.manager.stop()
213}
214
215func TestPreviewManagerPublicationStates(t *testing.T) {
216 now := time.Unix(100, 0)
217 unavailable := ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "preview unavailable"}
218 cases := []struct {
219 name string
220 withFrame bool
221 next func(ports.BrokerPreviewRequest) ports.BrokerPreviewPublication
222 want previewShown
223 }{
224 {name: "ok shows fresh frame", next: func(r ports.BrokerPreviewRequest) ports.BrokerPreviewPublication {
225 return publication(r, previewFrame(r, "hi"), nil)
226 }, want: previewShown{text: "hi"}},
227 {name: "error without frame", next: func(r ports.BrokerPreviewRequest) ports.BrokerPreviewPublication {
228 return publication(r, protocol.RemotePreview{}, unavailable)
229 }, want: previewShown{text: "preview unavailable", dim: true}},
230 {name: "error keeps last frame stale", withFrame: true, next: func(r ports.BrokerPreviewRequest) ports.BrokerPreviewPublication {
231 return publication(r, protocol.RemotePreview{}, unavailable)
232 }, want: previewShown{text: "hi", dim: true}},
233 {name: "gone without frame", next: func(r ports.BrokerPreviewRequest) ports.BrokerPreviewPublication {
234 return publication(r, protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewNoSuchTarget}, nil)
235 }, want: previewShown{text: "session gone", dim: true}},
236 {name: "gone keeps last frame stale", withFrame: true, next: func(r ports.BrokerPreviewRequest) ports.BrokerPreviewPublication {
237 return publication(r, protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewNoSuchTarget}, nil)
238 }, want: previewShown{text: "hi", dim: true}},
239 }
240 for _, tc := range cases {
241 t.Run(tc.name, func(t *testing.T) {
242 h := newPreviewHarness(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-a")))
243 sub, request := h.subscribe(1)
244 sub.EXPECT().Close().Maybe()
245 if tc.withFrame {
246 sub.EXPECT().Latest().Return(publication(request, previewFrame(request, "hi"), nil)).Once()
247 require.True(t, h.manager.publish(h.controller))
248 }
249 sub.EXPECT().Latest().Return(tc.next(request)).Once()
250 require.True(t, h.manager.publish(h.controller))
251 require.Equal(t, tc.want, h.shown())
252 })
253 }
254}
255
256func TestPreviewManagerIgnoresStaleGeneration(t *testing.T) {
257 now := time.Unix(100, 0)
258 h := newPreviewHarness(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-a")))
259 sub, request := h.subscribe(1)
260 sub.EXPECT().Close().Maybe()
261 stale := publication(request, previewFrame(request, "old"), nil)
262 stale.Generation = request.Generation + 1
263 sub.EXPECT().Latest().Return(stale).Once()
264 require.False(t, h.manager.publish(h.controller))
265 require.Equal(t, previewShown{text: "loading preview…", dim: true}, h.shown())
266}
267
268func TestPreviewManagerCacheHitNeverBlanks(t *testing.T) {
269 now := time.Unix(100, 0)
270 h := newPreviewHarness(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-a"), previewTestSession("beta", 2, "tab-b")))
271 first, firstRequest := h.subscribe(1)
272 first.EXPECT().Latest().Return(publication(firstRequest, previewFrame(firstRequest, "aa"), nil)).Once()
273 require.True(t, h.manager.publish(h.controller))
274 require.Equal(t, previewShown{text: "aa"}, h.shown())
275
276 first.EXPECT().Close().Once()
277 require.True(t, h.controller.ConsumeTerminalRead([]byte("j")))
278 second, secondRequest := h.subscribe(2)
279 second.EXPECT().Latest().Return(publication(secondRequest, previewFrame(secondRequest, "bb"), nil)).Once()
280 require.True(t, h.manager.publish(h.controller))
281
282 // Returning to the first row shows its cached frame immediately, marked
283 // stale, before the debounce even fires.
284 second.EXPECT().Close().Once()
285 require.True(t, h.controller.ConsumeTerminalRead([]byte("k")))
286 h.manager.refresh(h.service, h.controller, h.size)
287 require.Equal(t, previewShown{text: "aa", dim: true}, h.shown())
288}
289
290func TestPreviewCacheEvictsLeastRecentlyUsed(t *testing.T) {
291 var cache previewCache
292 key := func(i int) previewCacheKey {
293 return previewCacheKey{endpoint: "local", width: uint16(i + 1), height: 1}
294 }
295 for i := range previewCacheSize {
296 cache.put(key(i), protocol.RemotePreview{Revision: uint64(i + 1)})
297 }
298 _, ok := cache.get(key(0)) // key 0 becomes most recent
299 require.True(t, ok)
300 cache.put(key(previewCacheSize), protocol.RemotePreview{Revision: 99})
301 require.Len(t, cache.entries, previewCacheSize)
302 _, ok = cache.get(key(1))
303 require.False(t, ok, "the least recently used entry is evicted")
304 for _, i := range []int{0, 2, previewCacheSize} {
305 _, ok := cache.get(key(i))
306 require.True(t, ok, "entry %d is retained", i)
307 }
308 cache.put(key(0), protocol.RemotePreview{Revision: 100})
309 frame, ok := cache.get(key(0))
310 require.True(t, ok)
311 require.Equal(t, uint64(100), frame.Revision, "a re-put replaces the frame in place")
312 require.Len(t, cache.entries, previewCacheSize)
313}
314
315func TestPreviewManagerSubscribeFailureShowsUnavailable(t *testing.T) {
316 now := time.Unix(100, 0)
317 h := newPreviewHarness(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-a")))
318 h.service.EXPECT().SubscribePreview(h.expected(1)).Return(nil, ports.BrokerAdmissionClosed).Once()
319 h.manager.refresh(h.service, h.controller, h.size)
320 require.True(t, h.settle())
321 require.Equal(t, previewShown{text: "preview unavailable", dim: true}, h.shown())
322 require.Nil(t, h.manager.changed())
323}
324
325func TestPreviewManagerCloseStopsSubscription(t *testing.T) {
326 now := time.Unix(100, 0)
327 h := newPreviewHarness(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-a")))
328 sub, _ := h.subscribe(1)
329 sub.EXPECT().Close().Once()
330 h.manager.close(h.controller)
331 require.Nil(t, h.manager.sub)
332 require.Nil(t, h.manager.changed())
333 require.Equal(t, previewShown{}, h.shown())
334}
335
336func TestSupervisorRefreshPreviewWiresTerminalGeometryToPreviewManager(t *testing.T) {
337 now := time.Unix(100, 0)
338 h := newPreviewHarness(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-a")))
339 h.size = domain.Size{Cols: 80, Rows: 24}
340 expected := h.expected(1)
341 sub := portsmocks.NewMockBrokerPreviewSubscription(t)
342 h.service.EXPECT().SubscribePreview(expected).Return(sub, nil).Once()
343 sub.EXPECT().Close().Once()
344
345 supervisor := &Supervisor{cfg: SupervisorConfig{Picker: h.controller, Terminal: &pickerRecordingTerminal{}}, picker: pickerSession{preview: previewManager{clock: h.clock}}}
346 supervisor.refreshPreview(h.service)
347 h.clock.awaitTimer(t).fire()
348 select {
349 case <-supervisor.picker.preview.changed():
350 case <-time.After(5 * time.Second):
351 t.Fatal("the supervisor preview did not wake")
352 }
353 supervisor.picker.preview.publish(h.controller)
354 require.Equal(t, expected, supervisor.picker.preview.request)
355 supervisor.picker.preview.close(h.controller)
356}