v0.8.0 cad1d13

preview_manager_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close